第一章 JAVA虚拟机发展
------------- Sun公司研发的-------------
1. Classic 【jdk1.0 纯解释方式执行】
2 Exact VM 【jdk1.2 编译、解释 准确式内存管理】
可以知道内存中某个位置的具体类型, 在GC的时候能准确的判断数据是否还可能被使用。
3 HotSpot 【 热点代码探测 】
找出最有编译价值的代码, 通过JIT编译器以方法为单位进行编译。
如果一个方法被频繁调用-----→ 标准编译
如果一个方法中有效循环次数很多-----→ 栈上替换编译
通过编译器和解释器 协同工作,, → 在 最优化的程序响应时间 与 最佳执行性能中 取得平衡
4 KVM 【嵌入式市场 简单轻量可移植 慢】
--------------其他公司的通用JVM-----------------
5 Jrockit 【BEA 号称世界最快, 专注服务器端】
不太关注程序启动速度, 所以不含解析器实现, 全部都是编译后执行。
6 J9VM 【IBM 服务器、桌面应用、嵌入式全面考虑,】
--------------特定硬件 特高性能---------------
7 Azul VM【Azul Vega系统】
8 LiquidVM【BEA Hypervisor】
第二章 Java内存区域
1 为什么要了解
C++ 要自己管理所有的内存问题, java 在虚拟机自动内存管理机制下 不容易出现内存泄露和内存溢出问题, 但是如果出现了问题, 如果不了解虚拟机如何使用内存, 就难以排查问题如何发生。
2 运行时数据区域
Java虚拟机在执行java程序的过程中会把他所管理的内存划分为不同的数据区域, 根据JVM规范的规定 分为以下几个。
-----------------线程隔离的------------
1 程序计数器
当前线程 所执行的 字节码的 行号指示器
如果执行的是 Java方法, ------→ 正在执行的虚拟机字节码指令 的地址
如果执行的是Native方法, -----→ Undefined
2 java虚拟机栈
每个方法执行的时候会创建一个栈帧,
存储
1局部变量表、
2操作数栈、
3动态链接、
4方法出入口
局部变量表----- → 1 编译器可知的 各种基本数据类型
2 对象引用【指向对象起始地址或者代表对象的句柄】
3 ReturnAddress类型【一条字节码指令的地址】
因为局部变量表所需要的内存空间在编译期间就完成分配了。
.
所以在方法运行期间不会改变局部变量表的大小
3 本地方法栈
同 java虚拟机栈, 不过是为Native方法服务。
有些虚拟机也将本地方法栈和java虚拟机栈合二为一
-----------------线程共享的------------
4 java堆
虚拟机启动时创建 存放对象实例
随着JIT编译器发展, 也开始有了栈上分配
垃圾收集器管理的主要区域。
Java堆 ---------- 新生代------------Eden空间 8
老年代 From Survivor空间 1
To Survivor空间 1
5 方法区【永久代】
存储
已经被虚拟机加载的类信息
常量
静态变量
即时编译器编译后的代码。
垃圾收集比较少出现, 可以选择不实现垃圾收集。
主要回收目标 是 常量池的回收 、 类型的卸载
运行时常量池【动态性,运行时也可以将新的常量放入池中】
方法区的一部分, Class文件中除了类的版本、字段、方法、接口
还有常量池【class文件常量池】,存放编译器生成的 字面量和符号引用
在类加载后 进入 方法区的 运行时常量池
6 直接内存
想想还是把这块加上。直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致内存溢出问题。JDK1.4中新增加了NIO,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括RAM、SWAP区)大小以及处理器寻址空间的限制。
3 对象的创建
1 检查
JVM 遇到new指令,
首先检查 是否能在常量池中定位到这个 类的 符号引用
并检查这个符号引用代表的类 是否已经被 加载、解析、初始化过
【如果没 先类加载】
2 分配内存
检查通过后, 为新生对象 分配内存【所需大小在类加载完成后便可以确定】 。
这里还有2个问题。
如何分配:
① 指针碰撞: 空闲指针移动,要求已用、未用绝对规整各在一遍
② 空闲列表: 记录哪些内存块是可用的,划分并更新表
同步问题:
① CAS搭配失败重试, 保证更新操作的原子性
② 把内存分配的动作按线程划分不同空间, 预留内存 成为TLAB
【Thread Local Allocation Buffer】 线程局部缓存
在线程的TLAB中分配内存,仅当TLAB用完的时候才同步锁定
3 初始化零值
将内存空间都初始化为零值【不包含对象头】,保证不赋初值就可以使用。
4 必要设置
对对象进行必要的设置 都在对象头中【根据当前运行状态不同,字段不同】
包括: 这个对象是哪个类的实例
如何找到类的元数据
对象的哈希吗
对象的GC分代年龄
5 执行init方法
按程序员的意愿初始化,正式可用
4 对象的内存布局
1 Mark Word:存储对象运行时记录信息
占用内存大小与机器位数一样,即32位机占4字节,64位机占8字节
2 元数据指针
元数据指针:指向描述类型的Klass对象(Java类的C++对等体)的指针,Klass对象包含了实例对象所属类型的元数据,因此该字段被称为元数据指针,JVM在运行时将频繁使用这个指针定位到位于方法区内的类型信息。
是一个引用类型,因此正常来说64位机元数据指针应当为8字节,32位机元数据指针应当为4字节,但是HotSpot中有一项优化是对元数据类型指针进行压缩存储,使用JVM参数:
-XX:+UseCompressedOops开启压缩
-XX:-UseCompressedOops关闭压缩
HotSpot默认是前者,即开启元数据指针压缩,当开启压缩的时候,64位机上的元数据指针将占据4个字节的大小。换句话说就是当开启压缩的时候,64位机上的引用将占据4个字节,否则是正常的8字节。
3 数组长度
数组对象特有,一个指向int型的引用类型,用于描述数组长度,这个数据的大小和元数据指针大小相同,同样稍后说
4 实例数据
实例数据就是8大基本数据类型byte、short、int、long、float、double、char、boolean(对象类型也是由这8大基本数据类型复合而成),每种数据类型占多少字节就不一一例举了
5 填充
不定,HotSpot的对齐方式为8字节对齐,即一个对象必须为8字节的整数倍,因此如果最后前面的数据大小为17则填充7,前面的数据大小为18则填充6,以此类推
Mark Word
对象自身的运行时数据
哈希吗、 GC分代年龄、 锁状态标识、
线程持有的锁、偏向锁ID、偏向时间戳
不过由于对象需要存储的运行时数据很多,其实已经超出了32位、64位Bitmap结构所能记录的限度,但是对象头是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息
5 锁的升级
如上图所示,锁的状态共有四种:无锁态、偏向锁、轻量级锁和重量级锁,其中偏向锁和轻量级锁是JDK1.6开始为了减少获得锁和释放锁带来的性能消耗而引入的。
四种锁的状态会随着竞争情况逐渐升级,锁可以升级但是不能降级,意味着偏向锁可以升级为轻量级锁但是轻量级锁不能降级为偏向锁,目的是为了提高获得锁和释放锁的效率。用一张图表示这种关系:
偏向锁
HotSpot作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代码更低因此引入了偏向锁。偏向锁的获取过程为:
- 访问Mark Word中偏向锁的标识是否设置为1,锁标志位是否为01----确认为可偏向状态
- 如果为可偏向状态,则测试线程id是否指向当前线程,如果是,执行(5),否则执行(3)
- 如果线程id并未指向当前线程,通过CAS操作竞争锁。如果竞争成功,则将Mark Word中的线程id设置为当前线程id,然后执行(5);如果竞争失败,执行(4)
- 如果CAS获取偏向锁失败,则表示有竞争。当达到全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁(因为偏向锁是假设没有竞争,但是这里出现了竞争,要对偏向锁进行升级),然后被阻塞在安全点的线程继续往下执行同步代码
- 执行同步代码
有获取就有释放,偏向锁的释放点在于上述的第(4)步,只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的释放过程为:
- 需要等待全局安全点(在这个时间点上没有字节码正在执行)
- 它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态
- 偏向锁释放后恢复到未锁定(标识位为01)或轻量级锁(标识位为00)状态
轻量级锁
轻量级锁的加锁过程为:
- 在代码进入同步块的时候,如果同步对象锁状态为无锁状态,JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为Displaced Mark Word,此时线程堆栈与对象头的状态如图所示
- 拷贝对象头中的Mark Word复制到锁记录中
- 拷贝成功后,JVM将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向Object Mark Word,如果更新成功,则执行步骤(4),否则执行步骤(5)
- 如果更新动作成功,那么当前线程就拥有了该对象的锁,并且对象Mark Word的锁标识位设置为00,即表示此对象处于轻量级锁状态,此时线堆栈与对象头的状态如图所示
- 如果更新动作失败,JVM首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标识的状态值变为10,Mark Word中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入阻塞状态。而当前线程变尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程
偏向锁、轻量级锁与重量级锁的对比
下面用一张表格来对比一下偏向锁、轻量级锁与重量级锁,网上看到的,我觉得写得非常好,为了加深记忆我自己又手打了一遍:
6 对象的访问定位
建立对象是为了使用对象,Java程序需要通过栈上的reference(引用)数据来操作堆上的具体对象。比如我们写了一句
Object obj = new Object()
而new Object()之后其实有两部分内容,
一部分是类数据(比如代表类的Class对象)、
一部分是实例数据
由于reference在Java虚拟机规范中只是一个指向对象new Object()的引用obj,并没有规定obj应该通过何种方式去定位、访问堆中对象的具体位置,所以对象访问方式也是取决于虚拟机而定的。主流方式有两种:
1、句柄访问。Java堆中划分出一块句柄池,obj指向的是对象的句柄地址,句柄中则包含了类数据的地址和实例数据的地址【优点 对象被移动时只需要改变句柄中的指针】
2、指针访问。对象中存储所有的实例数据和类数据的地址,obj指向的是这个对象
【少了一次内存定位】
HotSpot虚拟机采用的是后者,不过前者的对象访问方式也是十分常见的。
第三章 垃圾收集器与内存分配
1 为什么要了解垃圾收集
尽管 内存的动态分配与内存回收技术已经相当成熟,
但是
当需要排查各种内存溢出、内存泄露问题时,
当垃圾收集成为系统达到更高并发量的瓶颈时,
就需要对这些自动化的技术实施必要的监控和调节。
2 垃圾收集的区域
线程隔离的内存运行时区域, 生命周期和线程一样,所以这几个区域的内存分配和回收都具有确定性, 所以不需要过多考虑。
主要讨论 java堆和方法区。
3 如何判断对象是否存活
1 引用计数法
给对象添加引用计数器。
效率高
无法解决循环引用问题
2 可达性分析法
这个算法的基本思想是通过一系列称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链(即GC Roots到对象不可达)时,则证明此对象是不可用的。在Java语言中可以作为GC Roots的对象包括:
· 虚拟机栈中引用的对象
· 方法区中静态属性引用的对象
· 方法区中常量引用的对象
· 本地方法栈中JNI(即Native方法)引用的对象
4 四种引用
在JDK1.2之前,Java中引用的定义很传统:
如果引用类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过于狭隘,一个对象只有被引用或者没被引用两种状态。
我们希望描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。
在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用4种,这4种引用强度依次减弱。
1、强引用【不回收】
代码中普遍存在的类似"Object obj = new Object()"这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象
2、软引用【将要溢出才回收】
描述有些还有用但并非必需的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。Java中的类SoftReference表示软引用
3、弱引用【只能活到下次回收 ,下次一定回收】
描述非必需对象。被弱引用关联的对象只能生存到下一次垃圾回收之前,垃圾收集器工作之后,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。Java中的类WeakReference表示弱引用
4、虚引用【只是为了回收时收到一个通知】
这个引用存在的唯一目的就是在这个对象被收集器回收时收到一个系统通知,被虚引用关联的对象,和其生存时间完全没关系。Java中的类PhantomReference表示虚引用
5 finalize()方法
对象最后一次自我拯救的机会
可达性分析法中, 要确定死亡, 要标记两次
第一次标记的时候进行筛选看是否 要执行finalize方法,
如果没有覆盖finalize方法
或者已经执行过finalize方法
则没有必要执行。 则马上第二次标记的时候就会被回收
如果对象有必要执行finalize方法, 则会被放进一条队列中,由一条线程去执行finalize方法,但是不确保一定执行。
finalize方法只会被执行一次, 所以必须重写finalize方法,使得让他在那个队列上被执行finalize方法的时候重新被GC ROOT 引用,才能逃脱被回收的命运
当然 因为JVM不保证finalize被执行, 所以即使重写了finalize,自救也不一定能成功。
6 方法区回收
HotSpot中为永久代, 回收效率很低, 可以不要求虚拟机在方法区实现垃圾收集。
方法区主要回收2个部分内容
① 废弃常量
没有任何对象引用该常量
② 无用的类
========= 该类所有实例已经被回收
加载该类的classloader已经被回收
该类的class对象没有被引用或者反射访问。
在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,以保证方法区不会溢出。
7 垃圾回收算法
1、标记-清除(Mark-Sweep)算法
这是最基础的算法,标记-清除算法就如同它的名字样,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。
这种算法的不足主要体现在效率和空间,
① 从效率的角度讲,标记和清除两个过程的效率都不高;
② 从空间的角度讲,标记清除后会产生大量不连续的内存碎片, 内存碎片太多可能会导致以后程序运行过程中在需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。
标记-清除算法执行过程如图:
2、复制(Copying)算法
复制算法是为了解决效率问题而出现的,
它将可用的内存分为两块,每次只用其中一块,当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次性清理掉。
这样每次只需要对整个半区进行内存回收,内存分配时也不需要考虑内存碎片等复杂情况,只需要移动指针,按照顺序分配即可。复制算法的执行过程如图:
不过这种算法有个缺点,内存缩小为了原来的一半,这样代价太高了。现在的商用虚拟机都采用这种算法来回收新生代,不过研究表明1:1的比例非常不科学,
因此新生代的内存被划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。每次回收时,将Eden和Survivor中还存活着的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden区和Survivor区的比例为8:1,意思是每次新生代中可用内存空间为整个新生代容量的90%。当然,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖老年代进行分配担保(Handle Promotion)。
3、标记-整理(Mark-Compact)算法
复制算法在对象存活率较高的场景下要进行大量的复制操作,效率很低。万一对象100%存活,那么需要有额外的空间进行分配担保。老年代都是不易被回收的对象,对象存活率高,因此一般不能直接选用复制算法。根据老年代的特点,有人提出了另外一种标记-整理算法,过程与标记-清除算法一样,不过不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。标记-整理算法的工作过程如图:
4 分代收集
根据上面的内容,用一张图概括一下堆内存的布局
现代商用虚拟机基本都采用分代收集算法来进行垃圾回收。这种算法没什么特别的,无非是上面内容的结合罢了,根据对象的生命周期的不同将内存划分为几块,然后根据各块的特点采用最适当的收集算法。
大批对象死去、少量对象存活的,使用复制算法,复制成本低;【新生代】
对象存活率高、没有额外空间进行分配担保的,采用标记-清理算法或者标记-整理算法。 【老年代】
8 垃圾收集器
--------------复制算法、 新生代-------------------------------
1、Serial 【单线程、 适用桌面应用】
最基本、发展历史最久的收集器,这个收集器 采用复制算法 单线程
它只会使用一个CPU或一条线程去完成垃圾收集工作,
它进行垃圾收集时必须暂停其他线程的所有工作,直到它收集结束为止。
用户桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代停顿时间在几十毫秒最多一百毫秒,只要不是频繁发生,这点停顿是完全可以接受的。
2、ParNew 【多线程 Server模式首选 自定义线程数】
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集外,其余行为和Serial收集器完全一样,包括使用的也是复制算法。
Server模式下的虚拟机首选的新生代收集器,
其中有一个很重要的和性能无关的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作(看图)。
它默认开启的收集线程数与CPU数量相同,在CPU数量非常多的情况下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。
3、Parallel【并行 吞吐量优先。Server默认 自动调整】
Parallel收集器也是一个新生代收集器,也是用复制算法的收集器,也是并行的多线程收集器,但是它的特点是它的关注点和其他收集器不同。介绍这个收集器主要还是介绍吞吐量的概念。
CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel收集器的目标则是打到一个可控制的吞吐量。
所谓吞吐量的意思就是CPU用于运行用户代码时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总运行100分钟,垃圾收集1分钟,那吞吐量就是99%。
另外,Parallel收集器是虚拟机运行在Server模式下的默认垃圾收集器。
虚拟机提供了-XX:MaxGCPauseMillis和-XX:GCTimeRatio两个参数来精确控制最大垃圾收集停顿时间和吞吐量大小。不过不要以为前者越小越好,GC停顿时间的缩短是以牺牲吞吐量和新生代空间换取的。由于与吞吐量关系密切,Parallel收集器也被称为“吞吐量优先收集器”。
Parallel收集器有一个-XX:+UseAdaptiveSizePolicy参数,这是一个开关参数,这个参数打开之后,就不需要手动指定新生代大小、Eden区和Survivor参数等细节参数了,虚拟机会根据当亲系统的运行情况手机性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
如果对于垃圾收集器运作原理不太了解,以至于在优化比较困难的时候,使用Parallel收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成将是一个不错的选择。
--------------标记整理算法、 老年代-------------------------------
4、Serial Old 【CMS后备 1.5之前与parallel合作】
Serial收集器的老年代版本,同样是一个单线程收集器,使用“标记-整理算法”,这个收集器的主要意义也是在于给Client模式下的虚拟机使用。
5、Parallel Old【 1.5之后 配合parallel】
Parallel收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器在JDK 1.6之后的出现,“吞吐量优先收集器”终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel收集器+Parallel Old收集器的组合。
--------------标记清除算法、 新生代-------------------------------
6、CMS【最短回收停顿时间为目标 配合ParNew 】
CMS收集器是一种以获取最短回收停顿时间为目标的老年代收集器。目前很大一部分Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其注重服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验,CMS收集器就非常符合这类应用的需求。CMS收集器从名字就能看出是基于“标记-清除”算法实现的。
缺点:
① CPU少,并发时吞吐量低
② 无法处理浮动垃圾,初始标记之后又有新垃圾,只能等到下次回收
③ 大量空间碎片, 出发FullGC
-------------- 不分新生代 老年代-------------------------------
7、G1 【最具有回收价值的rigion】
G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一,JDK 7 Update 4后开始进入商用。
在G1收集器之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1收集器不再是这样,
使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region的集合。
G1收集器跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也是Garbage-First名称的由来)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
9 垃圾收集器常用参数
(1)-Xms20M
表示设置JVM启动内存的最小值为20M,必须以M为单位
(2)-Xmx20M
表示设置JVM启动内存的最大值为20M,必须以M为单位。将-Xmx和-Xms设置为一样可以避免JVM内存自动扩展。大的项目-Xmx和-Xms一般都要设置到10G、20G甚至还要高
(3)-verbose:gc
表示输出虚拟机中GC的详细情况
(4)-Xss128k
表示可以设置虚拟机栈的大小为128k
(5)-Xoss128k
表示设置本地方法栈的大小为128k。不过HotSpot并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说这个参数是无效的
(6)-XX:PermSize=10M
表示JVM初始分配的永久代的容量,必须以M为单位
(7)-XX:MaxPermSize=10M
表示JVM允许分配的永久代的最大容量,必须以M为单位,大部分情况下这个参数默认为64M
(11)-XX:NewRatio=4
表示设置年轻代:老年代的大小比值为1:4,这意味着年轻代占整个堆的1/5
(12)-XX:SurvivorRatio=8
表示设置2个Survivor区:1个Eden区的大小比值为2:8,这意味着Survivor区占整个年轻代的1/5,这个参数默认为8
(13)-Xmn20M
表示设置年轻代的大小为20M
(18)-XX:PretenureSizeThreshold=3145728
表示对象大于3145728(3M)时直接进入老年代分配,这里只能以字节作为单位
(19)-XX:MaxTenuringThreshold=1
表示对象年龄大于1,自动进入老年代
(20)-XX:CompileThreshold=1000
表示一个方法被调用1000次之后,会被认为是热点代码,并触发即时编译
10 Minor GC 和 Full GC
新生代GC(Minor GC)
指发生在新生代的垃圾收集动作,因为大多数Java对象存活率都不高,所以Minor GC非常频繁,一般回收速度也比较快
老年代GC(Major GC/Full GC)
指发生在老年代的垃圾收集动作,出现了Major GC,经常会伴随至少一次的Minor GC(但并不是绝对的)。Major GC的速度一般要比Minor GC慢上10倍以上
第四章 类加载机制
1 概述
(类加载) 机制: 把描述类的数据从 Class文件加载到内存, 并对数据 进行校验、转换解析、 初始化, 最终形成可以被虚拟机直接使用的java类型。
Java语言中, 类型的加载、连接、初始化过程都是在程序运行期间完成的,所以(类加载)时期稍微增加性能开销,但是为java应用程序提供了灵活性。
【 比如运行时从其他来源获得二进制流从而成为代码的一部分】
Java语言特性: 运行期动态加载、 动态连接
2 类使用的7个阶段
使用 不考虑, 除了解析这个阶段, 其他五个阶段 严格按照顺序开始(只是【开始】按顺序, 可以并行交叉执行), 而解析可以发生在初始化后进行【这是为了支持java语言的运行时绑定。】
1 加载
主要是为了完成三件事情
1、通过类的全限定名 获取此类的二进制流
2、将字节流所代表的静态存储结构 → 方法区的 运行时数据结构
3、在内存中生成一个代表这个.class文件的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。这个Class对象一般是在堆里的,不过HotSpot虚拟机比较特殊,这个Class对象是放在方法区中的
虚拟机规范对这三点的要求并不具体,因此虚拟机实现与具体应用的灵活度都是相当大的。例如第一条,根本没有指明二进制字节流要从哪里来、怎么来,因此单单就这一条,就能变出许多花样来:
· 从zip包中获取,这就是以后jar、ear、war格式的基础
· 从网络中获取,典型应用就是Applet
· 运行时计算生成,典型应用就是动态代理技术
· 由其他文件生成,典型应用就是JSP,即由JSP生成对应的.class文件
· 从数据库中读取,这种场景比较少见
总而言之,在类加载整个过程中,这部分是对于开发者来说可控性最强的一个阶段。
2 验证
这一阶段的目的是为了确保 class文件 的 字节流中的信息 符合当前虚拟机的要求, 不会危害虚拟机的安全。
从整体上看, 验证阶段大致会完成下面4个阶段的校验动作
1 文件格式验证
魔数开头?
主次版本号?
常量池的tag类型是否正确
常量池索引是否存在
等等
通过了这个阶段后, 字节流才会进入内存中的方法区进行存储
以后的3个验证阶段是基于方法区的存储结构进行的。
2 元数据验证
对信息进行语义分析, 是否符合java语言规范
是否有父类?(除了Object都要有)
是否继承了不可继承的类
非抽象类是否实现了所有抽象方法
父子类是否矛盾?(比如覆盖了final方法)
等等
3 字节码验证
最复杂的阶段, 通过对数据流和 控制流的分析 ,确定程序语义合法、合逻辑,保证不会危害虚拟机
操作数栈放置了int类型,却要按long类型加载到本地变量
跳转指令跳转到了别的方法
类型转换有效
4 符号引用验证
发生在 虚拟机 将 符号引用 转换为直接引用的时候 【解析阶段】
验证常量池中的各种符号引用是否合法
比如 ,是否能找到对应的类, 指令类中是否有符合的方法或者字段等
目的是为了 确保 解析动作的正确执行
3 准备
准备阶段是正式为类变量分配内存并设置其初始值的阶段,这些变量所使用的内存都将在方法区中分配。
关于这点,有两个地方注意一下:
1、 这时候进行内存分配的仅仅是类变量(被static修饰的变量),而不是实例变量,实例变量将会在对象实例化的时候随着对象一起分配在Java堆中
2、这个阶段赋初始值的变量指的是那些不被final修饰的static变量,比如"public static int value = 123;",value在准备阶段过后是0而不是123,给value赋值为123的动作将在初始化阶段才进行;比如"public static final int value = 123;"就不一样了,在准备阶段,虚拟机就会给value赋值为123。
4 解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引用:
只是一组符号 描述所引用的目标, 只需要 使用的时候 可以无歧义的定位到目标即可。 所以与内存布局无关, 引用的目标甚至都不需要已经加载到内存中。
但是符号引用的字面量形式 明确定义在Class文件格式中, 所有的虚拟机能接受的符号引用都是一致的
直接引用:
直接指向目标的指针、 相对偏移量 或者间接定位到目标的句柄。
直接要引用与内存布局是相关的, 所以同一个符号引用在不同虚拟机上翻译出来的直接引用可能不会相同 。
另外, 如果有了直接引用, 则引用的目标必然已经在内存中存在。
5 初始化
前面阶段基本都是虚拟机主导控制【除了加载阶段可以自定义加载器参与】 。
到了初始化阶段才开始真正执行类中定义的java程序代码
准备阶段:为类变量和常量赋予初始默认值
初始化阶段:根据程序的定制去初始化类变量和其他资源【还是类变量,实例变量在实例化的时候才在堆里分配】
初始化阶段 → 执行 类构造器 clinit()方法。
Clinit()方法, 由编译器自动收集类中的所有
1 类变量的赋值动作
2 静态语句块
合并产生。
1 Clinit()的特点:
1、 静态语句块只能访问到定义在静态语句块之前的不变量,之后的变量可以赋值,不能访问。
2 、Clinit()方法不是类的构造方法init(), 不需要显式调用父类构造器,虚拟机会确保子类的clinit方法执行之前, 父类的clinit方法已经执行。
3 、Clinit方法不是必须的,如果没有静态语句块以及类变量的赋值操作, 就没clinit方法
4、接口的clinit方法不需要执行父接口的clinit方法
5 、虚拟机会保证类的clinit方法在多线程中的正确加锁和同步
2 必须初始化的场景【 主动引用】
Java虚拟机规范严格规定了有且只有5种场景必须立即对类进行初始化,这4种场景也称为对一个类进行主动引用(其实还有一种场景,不过暂时我还没弄明白这种场景的意思,就先不写了):
1、使用new关键字实例化对象、读取
或者设置一个类的静态字段(被final修饰的静态字段除外)、调用一个类的静态方法的时候
2、使用java.lang.reflect包中的方法对类进行反射调用的时候
3、初始化一个类,发现其父类还没有初始化过的时候
4、虚拟机启动的时候,虚拟机会先初始化用户指定的包含main()方法的那个类
3 不会触发初始化的场景【被动引用】
除了上面4种场景外,所有引用类的方式都不会触发类的初始化,称为被动引用
1、 子类引用父类静态字段,不会导致子类初始化。
2、 通过数组定义引用类,不会触发此类的初始化
3、 引用静态常量时,常量在编译阶段会存入类的常量池中,本质上并没有直接引用到定义常量的类
4 常量传播优化
在编译阶段通过常量传播优化,
常量HELLOWORLD的值"Hello World"实际上已经存储到了使用该常量的类的常量池中,以后该类对常量的引用实际上都被转化为该类对自身常量池的引用了。
也就是说,实际上的实际使用常量的类的Class文件中并没有ConstClass类的符号引用入口,这两个类在编译成Class之后就不存在任何联系了。
3 类加载器
1 概述
虚拟机设计团队把类加载阶段张的"通过一个类的全限定名来获取此类的二进制字节流"
这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。
实现这个动作的代码模块 称为"类加载器"。
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
上面说的"相等",包括代表类的.class对象的
equals()方法、
isAssignableFrom()方法、
isInstance()方法的返回结果,
也包括使用
instanceof关键字做对象所属关系判定等情况。
2 类加载器模型
从Java虚拟机的角度讲,只有两种不同的类加载器:
启动类加载器Bootstrap ClassLoader,这个类加载器是由C++语言实现的,是虚拟机自身的一部分;
其他类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全部继承自java.lang.ClassLoader。
1、启动类加载器Bootstrap ClassLoader
之前说过了这是一个嵌在JVM内核中的加载器。它负责加载的是JAVA_HOME/lib下的类库,系统类加载器无法被Java程序直接引用(因为是C++写的)
2、扩展类加载器Extension ClassLoader
这个类加载器由sun.misc.Launcher$ExtClassLoader实现,它负责用于加载JAVA_HOME/lib/ext目录中的,或者被java.ext.dirs系统变量指定所指定的路径中所有类库,开发者可以直接使用扩展类加载器。
3、应用程序类加载器Application ClassLoader
这个类加载器由sun.misc.Launcher$AppClassLoader实现。这个类也一般被称为系统类加载器,负责加载用户类路径上所指定的类库
3 双亲委派模型
关于这张图首先说两点:
1、这三个层次的类加载器并不是继承关系,而只是层次上的定义
2、它并不是一个强制性的约束模型,而是Java设计者推荐给开发者的一种类加载器实现方式
双亲委派模型要求除了顶层的启动类加载器外,其余的加载器都应当有自己的父类加载器,(以组合的方式实现)
双亲委派模型是在JDK1.2期间被引入的,其工作过程可以分为两步:
1、如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。
2、只有当父加载器反馈自己无法完成这这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载
双亲委派模型对于Java程序的稳定运作很重要,因为Java类随着它的加载器一起具备了一种带有优先级的层次关系。
例如java.lang.Object,存放于rt.jar中,无论哪一个类加载器要去加载这个类,最终都是由Bootstrap ClassLoader去加载,因此Object类在程序的各种类加载器环境中都是一个类。
相反,如果没有双亲委派模型,由各个类自己去加载的话,如果用户自己编写了一个java.lang.Object,并放在CLASSPATH下,那系统中将会出现多个不同的Object类,Java体系中最基础的行为也将无法保证,应用程序也将会变得一片混乱。
第五章 虚拟机字节码执行引擎
1 概述
执行引擎是java虚拟机最核心的组成部分,具有代码执行能力。
物理机的执行引擎 建立在处理器、硬件、指令集和操作系统层面,基于寄存器。
虚拟机的执行引擎基于栈,可以自行制定指令集和结构体系。
Java执行引擎包括解释执行和编译执行
解释执行: 直接把字节码通过执行引擎执行, 启动快, 占用内存, 效率低
编译执行: 通过即时编译器(JIT编译器)产生适用于硬件的本地代码再执行
一开始启动慢,但是有优化,省内存、效率高
2 运行时栈帧结构
栈帧: 用于支持虚拟机进行 方法调用 和 方法执行的 数据结构。
是虚拟机 运行时数据区中的 虚拟机栈的 栈元素。
每一个方法从调用开始到执行完成,都对应一个栈帧从虚拟机栈中从入栈到出栈的过程
方法调用链可能很长, 对于执行引擎来说, 只有位于栈顶的栈帧才是有效的,被称为当前栈帧。
1局部变量表
用于存放方法参数和 方法内部定义的局部变量。
.java文件被编译成.class文件时,方法的Code属性的max_locals就确定了该方法需要的局部变量表容量,以slot为单位进行分配,是32位的。
Long和double类型的话,有 非原子性协定, 读写都分割成2次32位读写。
方法执行时 使用局部变量表,完成 参数值 到参数变量列表的传递,如果是实例方法,第一个slot装this指针。
Slot是可以复用的,变量出了作用域之后slot可能被别的变量占用。
2操作数栈
最大深度在编译之后写入 Code属性的 max_stacks中。
操作数栈每一个元素32位,64位数据占用两个容量。
操作数栈的元素类型必须与指令序列严格匹配(验证阶段 字节码验证)
Java引擎 是 基于栈 的执行引擎 指的就是这个操作数栈
3动态链接
每个栈帧都有一个指向 运行时常量池中 该栈帧所属方法的 引用,用来支持方法调用中的动态连接。
Ps:
方法调用指令以常量池中指向方法的符号引用作为参数。
这部分符号引用
一部分在类加载阶段【或者第一次使用时】转换为直接引用---→ 静态解析
另一部分在运行期间转为 直接引用-------→动态连接。
4方法返回地址
方法执行后,只有两种方式可以退出方法
正常完成出口: 遇到方法返回的字节码指令,有返回值给上次的方法调用者
异常返回出口: 方法执行过程中遇到异常并且没有得到处理,不会产生返回值
无论如何退出, 都需要有返回到方法被调用的位置,程序才能继续执行 所以在栈帧中存有方法返回地址, 帮助恢复上层方法。
3 方法调用
方法调用 ≠ 方法执行
方法调用阶段: 确定被调用方法的版本。
1 解析调用
一部分方法在程序的真正运行之前就有一个可确定的调用版本,并且在运行期不可变怕【即在编译时确定方法调用的版本】 成为解析调用
在类加载的解析阶段就将符号引用转化为直接引用。
主要包括
静态方法------------通过invoke static指令调用
私有方法
实例构造器
父类方法-------------通过invoke special指令调用
Final方法-----------虽然是通过invoke virtual调用, 但是不可变,也是非虚方法。
2 分派调用 【重载看静态类型,重写看实际类型】
1 静态分派
输出: hello,guy!
hello,guy!
Human man=new Man();
我们把“Human”称为变量的静态类型,后面的“Man”称为变量的实际类型
编译器在编译期并不知道一个对象的实际类型是什么
编译器在重载时是通过参数的静态类型而不是实际类型作为判定的依据。
并且静态类型在编译期可知,因此,编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,其典型应用是方法重载(根据参数的静态类型来定位目标方法)。
静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机执行的。
往往不唯一, 往往确定一个 更加适合的版本
For Example
重载 char int long float double 的方法参数
运行sayHello(‘a’),会按顺序匹配,如果注释掉就用下一个
2 动态分派
输出:
man say hello!
woman say hello!
woman say hello!
我们从invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:
1、找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
2、如果在实际类型C中找到与常量中的描述符和简单名称相符合的方法,然后进行访问权限验证,如果验证通过则返回这个方法的直接引用,查找过程结束;如果验证不通过,则抛出java.lang.IllegalAccessError异常。
3、否则未找到,就按照继承关系从下往上依次对类型C的各个父类进行第2步的搜索和验证过程。
4、如果始终没有找到合适的方法,则跑出java.lang.AbstractMethodError异常。
由于invokevirtual指令执行的第一步 就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言方法重写的本质。
我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
3 虚拟机中动态分派的实现
为类 在方法区 建立一个虚方法表【除了invoke static和invoke special的方法】
Invoke virtual------→ 虚方法表
Invoke interface----→接口方法表
存放各个方法的实际入口地址,
如果方法在子类中没有被重写,那么子类的虚方法表里面的地址入口和父类的相同
如果子类重写了, 就会将方法表中的地址替换为子类的版本入口地址。
方法表一般在 类加载 的 连接阶段 初始化, 准备了类的变量初始值后也会初始化方法表
4 基于栈的字节码 解释执行引擎
1 解释执行
源代码----→ 词法分析---→ 语法分析-----转为为 抽象语法树
如果把这个过程的工具 和执行引擎 放在一个黑匣子中-----→代表 Javascrip执行器
而java是
源代码----→ 词法分析---→ 语法分析-----转为为 抽象语法树
然后 遍历 语法数 生成线性 的字节码指令流,
这部分都是在虚拟机外部,由javac编译器执行,
所以java程序的编译是半独立实现。
2 基于栈、 基于寄存器
Java的指令集架构都是基于栈的, 大部分是零地址指令, 依赖操作数栈进行工作
X86 基于寄存器, 大部分都是二地址指令集
基于栈的优点:
可移植,【寄存器硬件提供,操作数栈自己实现】
代码相对紧凑
编译器更加简单【都在栈上操作】
缺点: 稍微慢一点, 所以主流的物理机指令集都是基于寄存器的
第六章 编译期【早期】优化
1 概述
前端编译器: .java----→.class, ex: javac
后端运行时编译器【JIT编译器】:.class字节码----→机器码 ex:C1,C2
静态提前编译器【AOT编译器, Ahead Of Time 】:.java ---→机器码
虚拟机设计团队把对性能的优化集中到了后端的 JIT编译器中,
可以让那些不是由Javac产生的class文件【Jruby,Groovy】也同样享受编译器优化带来的好处。
Javac编译对代码的运行效率几乎没有优化措施, 但是也做了许多针对java语言编码的措施来改善程序员的编码风格和效率
相当多的新生java语法特性都是靠着编译器的 语法糖而不是依赖虚拟机底层
2 Javac编译器
本身就是由Java写的, 编译过程:
1 词法分析、语法分析
词法分析 语法分析
.java文件-----------------------→-Token集合----------------------→抽象语法树
之后的编译器都是操作抽象语法树,而不是字节流
2 填充符号表
符号表: 一组符号地址和符号信息构成的表格。
符号表登记的内容将在接下来的语义分析中用到
3 语义分析和字节码生成
语义分析又分为 标注检查 和 数据和控制流分析
标注检查: 变量是否声明、 赋值是否匹配、 常量折叠
数据和控制流分析: 局部变量是否赋值、方法是否返回路径
然后:
解语法糖: 泛型、 自动装箱/拆箱、 边长参数等, 还原成简单的语法结构
字节码生成: 转换为字节码写入磁盘
3 语法糖
语言都或多或少提供语法糖, 目的是方便程序员的代码开发
可以看做是编译器实现的小把戏。目的是便于程序员编程。
泛型: Java的泛型不真实存在,只存在在源码中,字节码其实是类型强转 擦除了泛型
自动装箱、拆箱
遍历循环: 实际上是迭代器。
条件编译: 根据布尔值把不成立的分支代码消除
、
第七章 运行期【晚期】优化
1 解释器与编译器
解释器: 快启动、 效率低 、 省内存、 编译器激进优化逃生门
编译器: 翻译为本地代码, 执行效率高, 内存限制大
HotSpot内置了两个JIT编译器: C1【Client】 C2【Server】
默认采用解释器和其中一个编译器互相配合
可以纯解释执行, 但是不能纯编译执行。
JDK1.7 编译层次:
第0层: 解释执行, 且解释器不开启 性能监控
第1层: C1编译, 只进行简单可靠优化,可以让解释器加入性能监控逻辑
第2层: C2编译, 会开启一些编译耗时较长的优化,甚至进行不可靠的激进优化
2 编译对象
在运行过程中会被即时编译器编译的 热点代码 主要有两类
被多次调用的方法
被多次执行的循环体
对于第一种, 以整个方法为编译对象 【标准编译】
对于第二种, 依然以整个方法作为编译对象,且因为方法还在执行【还在栈上】 ,就发生编译了, 所以又被称为栈上编译 OSR 【 On Stack Replacement】
3 触发条件
如何判断是否热点代码
基于采样的热点探测: 周期查看各个线程栈顶。
优点: 实现简单 高校
缺点: 不精确
基于计数器的热点探测:为每个方法(甚至代码块)建立计数器,超过一定阈值就认定
HotSpot采用第二种,为每个方法准备了:
方法调用计数器
回边计数器
方法调用: 一段时间内方法被调用次数,然后看两个计数器之和是否超过阈值,超过就请求编译,但是执行引擎不会同步等待,还是先执行,只是等编译完成,方法的调用入口就更新。
而且方法的调用次数会周期性半衰减, 不然时间长了所有的都编译
代码块调用: 循环一直调用的话 ,就增加计数, 并且没有热度半衰减,当两个计数器之和超过阈值,他就会把方法计数器的值调整到溢出,这样下次进入的时候就会执行标准编译
4 编译优化技术
编译方式执行本地代码更快的原因
虚拟机解释执行字节码额外消耗时间
虚拟机设计团队吧对代码的优化措施几乎都放到了JIT编译器中。
超级多:
1方法内联
最重要的优化手段之一。它的目的主要有两个:去除方法调用的成本(如建立栈帧等)、为其他优化建立了良好的基础,方法内联膨胀之后可以便于在更大范围上采取后续的优化手段。
讲到这里,我们是否理解为什么要尽量把方法声明为final?因为Java有多态的存在,运行时调用的是哪个方法可以根据实际的子类来确定,极大地增强了灵活性,但是这样的话,编译期间同样也无法确定应该使用的是哪个版本,所以无法被内联。但是被声明为final的方法不一样,这些方法无法被重写,所以调用类A的B方法,运行时调用的必然是类A的B方法,可以被内联。
2公共子表达式消除
公共子表达式消除消除的含义是:如果一个表达式E已经计算过了,并且从先前的计算到现在E中的所有变量值都没有发生变化,那么E的这次出现就成为了公共子表达式。对于这种表达式,没有必要花时间再去对它进行计算,只需要直接用前面计算过的表达式结果替代E就可以了
3数组边界检查消除【根据数据流分析arr.length的值】
无论如何,为了安全,数组边界检查肯定是必须做的,但数组边界检查是不是必须在运行期间一次不漏地检查则是可以商量的。比如数组下标是一个常量,只要在编译期间根据数据流分析来确定foo.length的值,并判断下标有没有越界,执行的时候就不需要判断了。更加常见的情况是数组访问发生在循环之中,并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析尽可以判定循环变量的取值范围永远在区间[0, foo.length)之间,那整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断操作。
4 逃逸分析
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中去,称为方法逃逸。
甚至可能被外部线程访问到,比如赋值给类变量或可以在其他线程中访问到的实例变量,称为线程逃逸。
如果能证明一个对象不会逃移到方法外或者线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化:
(1)栈上分配
Java虚拟机中,对象在堆上分配这个众所周知。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但回收动作无论是筛选可回收对象还是回收和整理内存都要耗费时间。如果确定一个对象不会逃逸出方法之外,那么让这个对象在栈上分配将会是一个不错的主意,对象所占用的内存空间就可以随着栈帧出栈而销毁,这样垃圾收集系统的压力将会小很多
(2)同步消除
线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定不会有颈枕,对这个变量实施的同步措施也就可以消除掉
(3)标量替换
标量是指一个数据已经无法再分解成更小的数据来表示了,Java中的基本数据类型即引用类型都不能进一步分解,因此,它们可以称为标量。相对的,一个数据如果还可以继续分解,那么就称为聚合量,Java中的对象就是最典型的聚合量。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员在栈上分配和读写外,还可以为后续进一步的优化手段创建条件。
第八章 java内存模型
1 内存模型的存在原因
1 计算机的处理器运算速度与它的存储和通信子系统差距太大,大量时间花费在磁盘IO、网络通信、数据库访问上。所以需要让计算机同时处理几项任务,把处理器的运算能力压榨出来
2 但是 “让计算机并发执行若干任务”与 “充分利用计算机处理器的效能” 之间关系没有那么简单, 因为大多数任务不能单独倚靠处理器完成, 需要与内存交互
3 由于计算机的存储设备与处理器运算速度有几个数量级的差距, 所以不得不加入一层读写速度接近处理器的高速缓存Cache来作为内存与处理器之间的缓冲
4 基于Cache的存储交互解决了处理器与内存的速度矛盾, 也引入的缓存一致性问题: 每个处理器都有自己的高速缓存,又共享同一个主内存,
当多个处理器涉及到同一块内存时,可能导致各自的缓存数据不一致。
5 所以需要各个处理器访问缓存时 遵守一些协议来进行读写
-----→ 内存模型:
在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程的 抽象,为了防止缓存不一致的问题的发生。
2 java内存模型的目的
物理机有自己的内存模型,java虚拟机也有自己的内存模型
Java内存模型
用来屏蔽掉各种 硬件和OS的 内存访问差异, 实现让 java程序在各个平台下都能达到一致的内存访问效果。
需要:
既严谨----→ 并发内存访问操作没有歧义
又宽松----→ 虚拟机的实现有空间去利用硬件的特性来优化运行速度
3 java内存模型的实现
目标,是定义程序中各个变量的访问规则,从内存中取出和存储进去这样的底层细节
1 工作内存、 主内存 交互
Java内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量和主内存副本拷贝
线程对变量所有的操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。
不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图:
2 交互协议
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面体积的每一种操作都是原子的、不可再分的:
1. 8种原子操作
1、lock(锁定):作用于主内存中的变量,它把一个变量标识为一条线程独占的状态
2、unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
3、read(读取):作用于主内存中的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用 4、load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
|
5、use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,没当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作
6、assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
7、store(存储):作用于工作内存中的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用 8、write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中
|
2 原子操作需要满足的规则
Java内存模型还规定了在执行上述8种基本操作时必须满足以下规则:
1、不允许read和load、store和write操作之一单独出现
2、不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了滞后必须把该变化同步回主内存
3、不允许一个线程无原因地把数据从线程的工作内存同步回主内存中
4、一个新的变量只能从主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量
5、一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁
6、如果对同一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值
7、如果一个变量事先没有被lock操作锁定,那就不允许对它进行unlock操作,也不允许去unlock一个被其他线程锁定的变量
8、对一个变量执行unlock操作之前,必须先把此变量同步回主内存中
3 关于volatile的特殊规定
关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制。
一个变量被定义为volatile后,它将具备两种特性:
1、 保证此变量对所有线程的"可见性",所谓"可见性"是指当一条线程修改了这个变量的值,新值对于其它线程来说都是可以立即得知的,而普通变量不能做到这一点,普通变量的值在在线程间传递均需要通过主内存来完成,关于volatile关键字的操作请参见volatile关键字使用举例,再强调一遍,volatile只保证了可见性,并不保证基于volatile变量的运算在并罚下是安全的
2、使用volatile变量的第二个语义是禁止指令重排序优化,普通变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
总结一下Java内存模型对volatile变量定义的特殊规则:
1、在工作内存中,每次使用某个变量的时候都必须线从主内存刷新最新的值,用于保证能看见其他线程对该变量所做的修改之后的值
2、在工作内存中,每次修改完某个变量后都必须立刻同步回主内存中,用于保证其他线程能够看见自己对该变量所做的修改
3、volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序顺序相同
同时满足以下规则 volatile也可以保证原子性:
运算结果并不依赖变量的当前值 或者只有一个线程来修改变量的值
变量不需要与其他的状态共同参与不变约束
4 原子性可见性有序性
其实上述的原子操作和各种规则, 也就是围绕着并发过程中如何处理原子性、可见性、有序性来建立的。
1、原子性(Atomicity)
由Java内存模型来直接保证原子性变量操作包括read、load、assign、use、store、write,大致可以认为基本数据类型的访问读写是具备原子性的。如果应用场景需要一个更大的原子性保证,Java内存模型还提供了lock和unlock,尽管虚拟机没有把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块----synchronized关键字
2、可见性(Visibility)
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。volatile其实已经详细写了这一点,
其实synchronized关键字也是可以实现可见性的,synchronized的可见性是由"对一个变量执行unlock操作之前,必须先把此变量同步回主内存中"这条规则获得的。
另外,final关键字也可以实现可见性,因为被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this传递出去,那在其他线程中就能看见final字段的值。
3、有序性(Ordering)
Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。
Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,
而synchronized则是由"一个变量在同一时刻只允许一条线程对其进行lock操作"这条规则获得的,这条规则规定了持有同一个锁的两个同步块只能串行地进入
5 happened-before原则
上述的围绕去实现三个特性去建立的原子操作及其协定的 目的
就是为了 判断数据是否存在竞争、线程是否安全,所以这些所有的东西, 都等效于java中天然的隐含的先行发生原则
先行发生原则是指Java内存模型中定义的两项操作之间的偏序关系,
如果说操作A先行发生于操作B,那么操作A产生的影响能够被操作b观察到,"影响"包括修改了内存中共享变量的值、发送了消息、调用了方法等。
Java内存模型下有一些天然的,不需要任何同步协助器就已经存在的先行发生关系:
1、程序次序规则:在一个线程内,按照控制流顺序,控制流前面的操作先行发生于控制流后面的操作,说"控制流"是因为还要考虑到分支、循环结构
2、管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作
3、volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作
4、线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作
5、线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测
6、线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
7、对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始
8、传递新:如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A必然先行发生于操作C
Java语言无须任何同步手段保障就能成立的先行发生规则就只有上面这些额,
第九章 线程安全
1 线程安全概述
当多个线程访问一个对象时,如果
不用考虑这些线程在运行时环境下的调度和交替执行、
也不需要进行额外的同步
或者在调用方进行任何其他的卸掉操作
调用这个 对象的行为都可以获得正确的结果,
那么这个对象是线程安全的。
2 java中的线程安全
1 不可变
Final、
只要一个不可变的对象被正确构造出来(this没有逃逸)
那其外部的可见状态也不会改变。
2 绝对线程安全
完全满足 线程安全的定义、 但是java中线程安全的类大多都不是绝对线程安全的, 比如Vextor, 一个线程删, 一个线程遍历, 就会越界
3 相对线程安全
通常意义上的线程安全, 保证对这个对象单独操作是线程安全的。但是对于特定的操作顺序,还是需要同步手段去确保并发安全。 JavaAPI中的类
Vector、 HashTable
4 线程兼容
需要在调用端正确的使用同步手段。 Arraylist、 HashMap
5 线程对立
不管怎么同步都不能在多线程环境下并发使用。
Thread的 suspend 和 resume。
3 线程安全的实现方法
1 互斥同步【悲观策略】
同步: 多个线程并发访问数据时, 确保同一个时刻只被一个线程访问
互斥:实现同步的手段, 临界区、信号量、互斥量都是互斥的实现方式。
Java中的互斥同步:
1 Synchronized:
在同步快前后形成monitorenter 和monitorexit
2 JUC包:
很类似,但是是API层面的互斥,且多了可中断、可公平锁、多条件绑定等
2 非阻塞同步【乐观策略,基于冲突检测】
互斥同步属于悲观并发策略,认为只要不去做正确的同步措施就一定会出现问题, 但是java的线程是系统线程, 需要用户核心态转换、维护计数器、检查阻塞等。
随着硬件指令集的发展(CAS),我们有了基于冲突检测的乐观并发策略:
先进行操作,如果没有竞争就成功
如果产生冲突,就采取补偿措施
为什么需要硬件指令集的发展呢?
因为需要操作 和 冲突检测 这两个步骤都具有原子性。。所以需要硬件保证。
但是CAS操作 有ABA问题(改成B又改回A)。 检测不到
3 无同步方案
1 可重入代码:
不依赖堆上的数据和公用系统资源。
2 线程本地存储
把共享数据可见范围限制在同一个线程内
4 锁优化
1 自旋锁
互斥同步对性能的最大影响就是阻塞的实现,
阻塞 挂起线程和恢复线程, 都需要用户态和内核态的转换。
有时候共享数据的锁定状态只持续很短时间, 去挂起和恢复线程 不值得。我们让其他处理器稍等一下【忙循环】, 不放弃处理器时间,看看是否有线程很快释放锁。
JDK1.6后 自适应自旋锁, 自旋时间不固定,由同一个锁上的自旋时间和拥有者的状态决定。
2 锁消除
对一些代码上要求同步, 但是检测出来不存在共享数据竞争的锁 进行消除
判定依据来自于: 逃逸分析的数据支持
3 锁粗化
一系列连续操作对同一个对象反复加锁解锁, 造成不必要的性能损耗。
如果虚拟机探测到有这样一串零碎的操作, 就会把加锁同步的范围粗化到整个操作序列外部。
4 轻量级锁
CAS Mark word , lock record。
无竞争情况下使用CAS去消除同步使用的互斥量
5 偏向锁
无竞争情况下使用CAS去消除 整个同步 连CAS都不做了
CAS改Mark word, 把线程id存进去。 如果以后还是整个线程, 就直接进去。