JVM学习笔记

JVM学习笔记

内存区域与内存溢出异常

运行时数据区域

  1. 程序计数器:线程私有,负责指令跳转,执行Java方法时记录虚拟机字节码指令地址,执行Native方法时为空。
  2. 虚拟机栈:线程私有,存储栈帧。

    • 栈帧:方法运行时的基础数据结构,方法执行时创建,用于存储局部变量表,操作数栈,动态链接,方法出口等信息。
    • 局部变量表:由若干个局部变量空间(Slot)组成,存放编译器可知的基本数据类型,对象的引用和返回地址类型,其中64位整数long和浮点数double的数据会占用2个空间(Slot),其余数据类型只占用一个。局部变量表的内存空间是在编译时完成分配的,在方法运行期间不会发生改变。
  3. 本地方法栈:规范中无强制规定,在Hotspot VM中与虚拟机栈合二为一。
  4. 堆:线程共享,虚拟机启动时创建,存放对象实例。也叫GC堆。
  5. 方法区:线程共享,存放虚拟机加载的类,常量,静态变量,JIT编译后的代码等数据。Oracle JDK1.6及之前方法区位于堆的永久代中,对于JDK 1.7,在堆中开辟了一块区域存放方法区,1.8之后,方法区彻底从堆中移除,方法区被存放在一个与堆不相连的内存区域,称为元空间。
  6. 运行时常量池:方法区的一部分,存放编译期生成的各种字面量和符号引用。
  7. 直接内存:不属于运行时数据区域,通过NIO类通过Native函数库直接分配堆外内存。

运行时内存溢出异常

  1. 程序计数器:无
  2. 虚拟机栈:

    • StackOverflowError: 线程请求的栈深度大于虚拟机所允许的最大深度。
    • OutOfMemoryError:如果虚拟机栈可以动态扩展,而线程无法申请到足够的内存。
  3. 本地方法栈:与虚拟机栈相同。
  4. 堆:

    • OutOfMemoryError:如果堆中没有足够的内存来完成实例分配,并且堆也无法进行扩展。
  5. 方法区:

    • OutOfMemoryError:如果方法区中没有足够的内存来完成实例分配,并且方法区也无法进行扩展。
  6. 运行时常量池:与方法区相同。
  7. 直接内存:

    • OutOfMemoryError:动态扩展时,超出了物理内存或操作系统的限制。

虚拟机对象

对象的创建

  1. 类加载检查
  2. 分配内存

    • 指针碰撞
    • 空闲列表
  3. 初始化对象为零值
  4. 设置对象头
  5. 调用构造方法

对象的内存布局

  1. 对象头

    • 运行时数据(Mark Word):HashCode,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等。
    • 类型指针:指向对象的类元数据,虚拟机通过这个指针判断对象是哪个类的实例。
    • 数组长度:记录数组的长度。
  2. 实例数据

    • 存储顺序:受虚拟机分配策略参数(FieldAllocationStyle)和字段定义顺序影响,Hotspot VM的默认顺序为:longs/doubles, ints, shorts/chars, bytes/booleans, oops(Ordinary Object Pointers),即相同长度字段被分配在一起,除此之外,父类成员总是在子类成员之前。若CompactFields参数为true,则子类中长度较小的字段也可能被插入到父类字段的间隙中。
  3. 对齐填充:不一定存在,起占位填充的作用。Hotspot VM中的自动内存管理系统要求对象的起始地址必须是8的整数倍,也就是对象的大小必须是8的整数倍,而对象头部分正好是8字节的倍数,所以实例数据部分不对齐时需要通过对齐填充来补全。

对象的访问定位

  1. 句柄:需要在堆中划分一块区域来存储句柄池,虚拟机栈中的局部变量表中的对象引用存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的地址信息。优点:对象引用存储的地址是稳定的,在对象被移动(如GC时)只需要改变句柄中的实例数据的地址。
    通过句柄访问对象
  2. 直接指针:需要在堆中存放对象的类型数据信息,而对象引用存储的是直接指向对象的地址。优点:速度快,节省了一次指针定位的开销。Hotspot VM采用本方法进行对象访问定位
    通过直接指针访问对象

垃圾收集器与内存分配策略

对象的生存与死亡

引用计数与可达性分析算法

  1. 引用计数:当对象被引用时,计数器+1;当引用失效时,计数器-1。效率高,但无法解决循环引用的问题。
  2. 可达性分析:以GC Root为起始点,向下搜索,搜索走过的路径称为引用链,当从GC Root到对象不可达时,该对象为孤立对象,即可回收。

    • GC Root:

      • 虚拟机栈(栈帧中的局部变量表)中引用的对象
      • 方法区中类静态属性引用的对象
      • 方法区中常量引用的对象
      • 本地方法栈中JNI引用的对象

引用强度

  1. 强引用:程序代码中普遍存在的,如使用new操作符产生的引用。只要强引用还存在,系统将不会回收被强引用的对象。
  2. 软引用:SoftReference类实现,用来描述一些还有用但是并非必需的对象。当系统将要发生内存溢出时,软引用对象将被列入可回收的范围内并进行二次回收,若回收后仍然没有足够的内存,系统才会抛出内存溢出异常。
  3. 弱引用:WeakReference类实现,用来描述非必需对象。无论内存是否足够,弱引用对象都只能生存到发生下一次垃圾收集之前。
  4. 虚引用:PhantomReference类实现,仅用于在对象被回收时得到通知。虚引用完全不影响对象的生存时间,也无法通过虚引用来获取一个对象实例。

对象的“自我拯救”

  1. 覆盖finalize()方法,且尚未被系统调用
  2. 在finalize()方法中与引用链重新建立引用关系
  3. 这种方法只能使用一次,因为finalize()方法只能被系统调用一次,在面临下一次垃圾收集的时候,finalize()方法将不再被调用

方法区的回收

  1. 常量:与堆中的对象的回收类似,当没有字面量引用常量池的常量时,如有必要,常量将会被清理出常量池。
  2. 方法:与常量类似。
  3. 字段:与常量类似。
  4. 类:需要满足“无用的类”的条件,即使满足虚拟机也不一定会回收,可通过参数控制。

    • 无用的类判定条件:

      • 该类的所有实例都已被回收,即堆中不存在该类的任何实例。
      • 加载该类的ClassLoader已被回收。
      • 该类的java.lang.Class方法没有被引用,无法在任何地方通过反射访问该类的方法。

垃圾回收算法

  1. “标记-清除”算法:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。缺点:效率低;产生大量内存碎片。
    “标记-清除”算法示意图
  2. “复制”算法:将可用内存划分为大小相等的两块,每次只使用其中的一块,当进行垃圾回收时,将存活的对象复制到另一块上,再把原来的空间一次清理掉。优点:实现简单,运行高效。缺点:将内存缩小了一半,代价高昂;存活率较高时复制操作增加,效率降低。
    “复制”算法示意图

Hotspot VM对新生代的垃圾回收算法采用的是复制算法,但没有将两块内存划分为大小相等,而是分为Edenh和Survivor,比例为8:1,当Survivor空间不够用时,需要依赖其他内存进行分配担保。

  1. “标记-整理”算法:与“标记-清除”算法一样,都是先标记出所有需要回收的对象,但后续不是直接清理可回收的对象,而是让存活的对象向一端移动,然后清理掉端边界以外的内存。
    “标记-整理”算法示意图
  2. 分代收集算法:针对对象存活周期的不同将内存划分为若干块,对每一块采用合适的算法去进行垃圾回收。如新生代的存活率较低,则采用复制算法;老年代存活率较高,且没有额外内存提供分配担保,必须采用“标记-清除”或“标记-整理”算法。

Hotspot VM的实现

  1. OopMap:在类加载完成后记录下对象内的数据类型与偏移量,在JIT编译过程中记录下栈和寄存器中哪些位置是引用。
  2. 安全点:在指令序列复用时,如方法调用,循环跳转,异常跳转等。
  3. 中断:

    • 抢先式中断:不需要线程代码配合,当GC发生时,先把所有线程都中断,如果发现线程中断的点不在安全点上,则恢复线程,让他跑到安全点上。
    • 主动式中断:当GC需要中断线程的时候,在安全点上设置中断标志,线程执行时主动轮询中断标志,发现标志为真时则主动中断挂起线程。
  4. 安全区域:指线程在某一段代码片段中,引用关系不会发生变化,在这个区域中的任意点开始GC都是安全的。在线程处于blocked或sleep状态无法跑到安全点时,采用安全区域可以避免GC长时间等待。

垃圾收集器

  1. Serial收集器:单线程,进行垃圾收集时必须暂停所有工作线程。Client模式下默认新生代收集器。采用复制算法。优点:简单高效,进行垃圾收集时不需要与工作线程交互。缺点:必须暂停所有工作线程,停顿时间较长。
  2. Serial Old收集器:Serial收集器的老年代版本。主要也是在Client模式下使用,在Server模式下,主要与Parallel Scavenge收集器搭配使用,或作为CMS收集器的后备预案。采用标记-整理算法。
    Serial / Serial Old 收集器运行示意图
  3. ParNew收集器:Serial收集器的多线程版本,除了能够多线程进行垃圾收集以外,其他与Serial收集器相似,进行垃圾收集时必须暂停所有工作线程。Server模式下默认新生代收集器。采用复制算法。在CPU核心数量较多的时候垃圾收集效率较高,但在CPU核心数量较少的时候效率不一定比Serial收集器高。
    ParNew / Serial Old 收集器运行示意图
  4. Parallel Scavenge收集器:与ParNew类似,也是多线程收集器,但更关注于达到一个可控制的吞吐量(用于执行用户代码的CPU时间与CPU总消耗时间的比值)。采用复制算法。
  5. Parallel Old收集器:Parallel Scavenge的老年代版本。采用标记-整理算法。
    Parallel Scavenge / Parallel Old 收集器运行示意图
  6. CMS收集器:并发收集器,追求最短回收停顿时间,适合追求服务响应速度的服务端应用。采用标记-清除算法。优点:收集过程中耗时最长的步骤都可以和用户工作线程并发工作。缺点:对CPU资源非常敏感,在CPU核心数较少的情况下,对应用的运行速度有较大影响;无法处理浮动垃圾,即在垃圾收集过程中总有新的垃圾不断产生,只能积累到下一次GC再收集;标记-清除算法容易导致产生大量内存碎片,从而提前触发Full GC。
    CMS 收集器运行示意图
  7. G1收集器:并行与并发;分代收集;空间整合;可预测的停顿。使用Region划分GC堆。
    G1 收集器运行示意图

并行收集器与并发收集器

  • 并行(Parallel)收集器:指多条垃圾收集线程并行工作,但此时用户工作线程仍然处于等待状态。
  • 并发(Concurrent)收集器:指用户工作线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户工作线程在继续执行,而垃圾收集程序运行与另一个CPU上。

内存分配与回收策略

  1. 对象优先在Eden分配:当Eden区空间不足时,将触发Minor GC。
  2. 大对象直接进入老年代
  3. 长期存活的对象将进入老年代:当对象的年龄增加到一定程度时(默认为15),它将会被移动到老年代中。

    • 对象的年龄:对象出生并且经过第一次Minor GC后仍然存活并且能被Survivor容纳的话,将被移动到Survivor空间中,并且年龄记为1,在Survivor空间中每经历一次Minor GC,对象的年龄就增加1。
  4. 动态年龄判定:如果Survivor空间中相同年龄的对象大小总和大于Survivor空间的一半,则年龄大于或等于该年龄的对象就可以直接进入老年代。
  5. 空间分配担保:新生代采用的是复制收集算法,Eden和Survivor始终只是用其中一块内存区,当出现Minor GC后大部分对象仍然存活的话,就需要老年代进行分配担保,把Survivor区无法容纳的对象直接晋升到老年代。

    • Minor GC是否安全:在进行Minor GC之前,虚拟机会先检查老年代的最大可用连续空间是否大于新生代所有对象总空间,如果大于,则此次Minor GC是安全的;否则则要检查HandlePromotionFailure参数是否允许担保失败,如果允许,则会继续检查老年代的最大可用连续空间是否大于历次进入老年代的对象的平均大小,如果大于,则尝试进行一次Minor GC;如果小于,或者不允许担保失败,则进行一次Full GC;如果尝试进行的Minor GC失败了,也要进行一次Full GC。

新生代GC与老年代GC

  • 新生代GC(Minor GC / Young GC):指发生在新生代的垃圾收集动作,Minor GC非常频繁,速度也比较快。
  • 老年代GC(Major GC / Full GC):指发生在老年代的垃圾收集动作,出现了Major GC,经常会伴随至少一次Minor GC。Major GC的速度一般会比Minor GC慢10倍以上。

TLAB

线程私有分配缓冲区(Thread Local Allocation Buffer),是所有线程都拥有的一块且只有该线程能够访问的内存区域,所以使用TLAB可以减少线程间同步加锁的操作。

假设TLAB能够保存100个对象,那么拥有该区域的线程只在申请第101个对象的空间时才需要加锁,而不是对每个对象都加锁。副作用是有潜在的可能会浪费一些内存空间。

TLAB适用于大量小对象的分配,大对象通常不会在TLAB内分配,因为用于分配大对象对减少加锁操作的想效果不明显,甚至TLAB可能存不下大对象。

Java 对象分配细节

  1. 编译器通过逃逸分析,确定对象是在栈上分配还是在堆上分配。如果是在堆上分配,则进入第2步;
  2. 如果tlab_top + size <= tlab_end,则在在TLAB上直接分配对象并增加tlab_top 的值,如果现有的TLAB不足以存放当前对象则进入第3步;
  3. 重新申请一个TLAB,并再次尝试存放当前对象。如果放不下,则进入第4步;
  4. 在Eden区加锁(这个区是多线程共享的),如果eden_top + size <= eden_end则将对象存放在Eden区,增加eden_top 的值,如果Eden区不足以存放,则进入第5步;
  5. 执行一次Young GC(Minor GC);
  6. 经过Young GC之后,如果Eden区任然不足以存放当前对象,则直接分配到老年代(空间分配担保)。

类文件结构

Class 类文件的结构

  1. 魔数和Class文件的版本
  2. 常量池
  3. 访问标志
  4. 类索引,父类索引与接口索引集合
  5. 字段表集合
  6. 方法表集合
  7. 属性表集合

虚拟机类加载机制

类的生命周期

  • 加载
  • 验证
  • 准备
  • 解析
  • 初始化
  • 使用
  • 卸载

验证,准备,解析三个部分统称为连接。

类加载的过程

加载

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生存一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

验证

  1. 文件格式验证
  2. 元数据验证
  3. 字节码验证
  4. 符号引用验证

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。注意对于静态变量设置初始值不是设置类变量被赋予的值,基本数据类型为对应的零值,引用类型为null;对于常量(即带有final修饰符的静态变量)则会直接设置其被赋予的值。

解析

解析阶段是将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口,字段,类方法,接口方法,方法类型,方法句柄和调用点限定符7类符号引用进行。

  • 符号引用:符号引用以一组符号来描述所引用的模板,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关。
  • 直接引用:直接饮用可以是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的。

初始化

初始化阶段是执行类构造器<clinit>()方法的过程(注意与<init>()方法区分)。

关于 <clinit>() 方法

  1. <clinit>()方法由所有类变量的赋值动作和静态语句块合并产生的,顺序由语句在源文件中出现的顺序所决定的,静态语句块只能访问在静态语句块之前的变量,定义在后面的变量,静态语句块内可以赋值但不能访问。
  2. 虚拟机会保证先调用父类的<clinit>()方法,所以在虚拟机中第一个被执行的<clinit>()方法的类是java.lang.Object,也意味着父类中定义的静态语句块要优先于子类。
  3. 接口也可能会生成<clinit>()方法,因为接口可能存在变量初始化的赋值操作。接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有父接口中定义的变量被使用时,父接口才会初始化。
  4. <clinit>()方法对于类或接口不是必需的,如果他们没有静态语句块和静态变量的赋值操作,那么编译器可以不为他们生成<clinit>()方法。
  5. 虚拟机会保证<clinit>()方法是线程安全的。

类的主动引用

对于类的初始化阶段,虚拟机规范中严格规定了有且仅有以下5种情况必须立即对类进行初始化,这5种情况中的行为称为对一个类进行主动引用。除此以外,所有引用类的方法都不会触发初始化,称为被动引用。

  1. 遇到new, getstatic, putstaticinvokestatic这4条字节码指令时,如果类尚未进行过初始化,则先触发类的初始化。如使用new关键字实例化对象,读取或设置一个类的静态字段(非final),调用一个类的静态方法。
  2. 使用java.lang.reflect包方法对类进行反射调用时。如果该类尚未进行过初始化,则先触发类的初始化。
  3. 初始化一个类时,如果该类的父类尚未进行过初始化,则先触发其父类的初始化。
  4. 虚拟机启动时,主类会被首先初始化。
  5. 使用JDK 1.7以上的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStaticREF_putStatic, REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类尚未进行过初始化,则需要先触发类的初始化。

类加载器

类加载器用于实现类的加载动作。比较两个类是否相等,只有在由同一个类加载器加载的前提下才有意义,对于使用equals()方法,isAssignableFrom()方法,isInstance()方法和instanceof关键字的结果都有影响。

  • 启动类加载器(Bootstrap ClassLoader):负责加载存放在JAVA_HOME/lib目录中或被-Xbootclasspath参数所指定的路径的,并且被虚拟机识别的类库。
  • 扩展类加载器(Extension ClassLoader):负责加载JAVA_HOME/lib/ext目录中或者被java.ext.dirs系统变量锁指定的路径中的类库。
  • 应用程序类加载器(Application ClassLoader):负责加载用户类路径(ClassPath)上所指定的类库。

双亲委派模型

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里的父子关系一般是以组合关系而不是继承关系来实现。
类加载器的双亲委派模型

双亲委派模型的工作过程

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈无法完成加载请求时,子加载器才会尝试自己去加载。

虚拟机字节码执行引擎

栈帧

栈帧结构

  1. 局部变量表
  2. 操作数栈
  3. 动态连接
  4. 方法返回地址
  5. 附加信息

局部变量表

  1. 变量槽(Slot):每个变量槽应该能存放一个boolean, byte, char, short, int, float, referencereturnAddress类型的数据,Slot的实际长度可以随着处理器,操作系统或虚拟机的不同而变化。对于64位数据类型,longdouble,存储时将以高位对齐的方式占用两个连续的变量槽。
  2. 索引定位:虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。
  3. 参数传递:虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程,当执行实例方法时,第0位变量槽默认用于传递方法所属对象实例的引用,即"this"。
  4. 局部变量:参数列表分配完毕后,根据方法体内部定义变量的顺序和作用域分配变量槽。
  5. 变量槽的复用:当代码执行离开了局部变量A的作用域之后,如果有新的局部变量被定义,那么A使用的变量槽就可以被新的局部变量复用。
  6. 局部变量的初始化:局部变量未经初始化不能被使用,局部变量不会被自动赋予零值。

操作数栈

  1. 栈元素:32位数据类型元素占用一个容量,64位数据类型元素占用两个容量。
  2. 作用:供字节码指令执行的时候,写入和提取内容使用。

动态连接

动态连接指在每一次运行期间将方法的符号引用转化为直接引用的。与之相对应的是静态解析,在类加载阶段或第一次使用时就转化为直接引用。

方法返回地址

  1. 正常完成出口:执行任意一个方法返回的字节码指令,是否给方法调用者产生返回值由字节码指令决定。
  2. 异常完成出口:在方法执行过程中遇到了异常,并且异常没有在方法体内得到处理,导致方法退出,不会给方法调用者产生返回值。
  3. 方法返回地址:一般来说,方法正常退出时,栈帧中的方法返回地址会使用调用者的PC计数器的值,而方法异常退出时,返回地址需要通过异常处理器表来确定。

方法调用

  1. 非虚方法:指在类加载阶段,可以确定调用版本,并且调用版本在运行期不可变的方法,主要包括主要包括静态方法和私有方法,以及使用final修饰的方法
  2. 解析:在类加载阶段,虚拟机会将非虚方法的符号引用转化为直接引用。
  3. 静态类型:也称为外观类型,指变量的字面值类型或表达式值类型。静态类型变化在编译期是可知的。
  4. 实际类型:指变量的在运行期时的类型。实际类型变化在编译期是不可知的。
  5. 静态分派:所有依赖静态类型来确定方法执行版本的分派动作称为静态分派。方法重载是静态分派。静态分派发生在编译期。
  6. 动态分派:通过实际类型来确定方法执行版本的分派动作称为动态分派。方法重写是动态分派。动态分派发生在运行期。
  7. 方法的宗量:方法的接受者与方法的参数统称为方法的宗量。
  8. 单分派与多分派:单分派是根据一个宗量来确定方法执行版本,多分派是根据多个宗量来确定方法执行版本。在Java中,静态分派属于多分派(分派时需要确定方法的接受者和参数),动态分派属于单分派(分派时方法的接受者已确定,只需确定方法的参数)。

动态语言支持

  1. MethodHandle:JDK 1.7新增的用于动态确定目标方法的机制,类似于C/C++中的函数指针或C#中的委托。
  2. invokedynamic指令:在字节码层面实现与MethodHandle相似的功能。

程序编译与代码优化

解释器与编译器

  • 解释器:对代码(字节码)解释执行,执行效率较低但可以省去编译的时间,快速启动程序。
  • 编译器:将代码编译成本地代码后执行,执行效率高,但需要花费时间在编译上。

Hotspot VM内的解释器与编译器

  1. 解释器(Interpreter)
  2. C1即时编译器(Client JIT Compiler)
  3. C2即时编译器(Server JIT Compiler)

分层编译

  • 第0层:程序解释执行,解释器不开启性能监控功能,可触发第1层编译。
  • 第1层:也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。
  • 第2层:也称为C2编译,也是将字节码编译为本地代码,但是会启用一些耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

热点探测

热点代码

  • 被多次调用的方法
  • 被多次执行的循环体

热点探测方式

  • 基于采样的热点探测:定期检查各线程的栈顶,统计出现在栈顶的方法,出现次数较多的认为是热点方法。优点:实现简单,高效,容易获取方法调用关系。缺点:难以精确统计方法的热度,易受线程阻塞或外界因素影响。
  • 基于计数器的热点探测:为方法或代码块建立计数器,统计方法执行的次数,执行次数超过一定阈值则认为是热点方法。优点:统计结果精确严谨。缺点:实现复杂,难以获取方法的调用关系。

Hotspot VM基于计数器的热点探测实现

  • 热点方法:方法调用计数器用于统计方法被调用的次数,当计数器的值超过阈值时,那么将会向即时编译器提交一个该方法的代码编译请求,而后方法将被继续解析执行,直到编译完成,系统会自动改写方法的调用入口地址。

    • 热度衰减及半衰周期:在一定时间限度后,如果方法的调用次数仍然未达到阈值,则这个方法的调用计数会被减少一半,这称为方法调用计数器的热度衰减,这个时间称为方法的半衰周期。
  • 热点循环:回边计数器用于统计一个方法中循环体代码的执行次数,当计数器的值超过阈值时,将会提交栈上替换请求,同样循环将被继续解析执行,直到编译完成。回边计数器没有热度衰减。

编译优化技术

  • 公共子表达式消除:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为了公共子表达式,可使用之前计算的值直接代替。
  • 数组边界检查消除:在编译期根据数据流分析确定数组访问不会超出数组的长度范围时,可以将数组上下界检查消除。
  • 方法内联:一个方法被多次调用时,可以将方法体替换到调用处,从而节省方法调用的开销并可为进一步优化建立良好的基础。
  • 类型继承关系分析:用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类,子类是否为抽象类等信息。
  • 逃逸分析:分析对象动态作用域,如对象是否被作为参数传递到其他方法中,称为方法逃逸;是否能被其他线程访问到,称为线程逃逸。

    • 栈上分配:对于确定不会出现方法逃逸的对象,可以将其在栈上进行分配,那么对象将随着方法退出而自动销毁,降低垃圾收集的压力。
    • 同步消除:对于确定不会出现线程逃逸的对象,可以消除其同步措施,节省同步的耗时。
    • 标量替换:如果一个对象不会被外部访问,并且可以被拆散为若干标量的话,那么对象可能不会被真正创建,而是改为创建它的若干个被使用到的成员变量来代替,这些成员变量可能会被创建在栈上。

Java内存模型与线程

Java内存模型

主内存与工作内存

  • 主内存:主要包括方法区和堆。
  • 工作内存:每个线程拥有一个工作内存,主要包括属于该线程私有的栈和对主内存部分变量拷贝的寄存器。

注意

  1. 所有的变量都存储在主内存中,主内存对所有线程共享。
  2. 线程对变量的所有操作必须在工作内存中进行,不能直接读写主内存中的变量。
  3. 不同线程之间也无法直接访问对方的工作内存,线程间变量值的传递均需要通过主内存来完成。

内存间交互操作

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

以上8种操作都是原子的,不可再分的,且执行上述基本操作时必须满足下列规则:

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说,就是对一个变量实施use、store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

volatile变量

  1. 对所有线程具有可见性:当一个线程修改了volatile变量的值,新值对于其他线程来说是可以立即得知的(使用前必须先刷新)。
  2. 禁止指令重排序优化。

非原子性协定(对于long和double型变量的特殊规则)

JVM规范中允许虚拟机实现选择可以不保证64位数据类型的load, store, read和write这4个操作的原子性,即所谓的非原子性协定。实际上目前主流商用虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待。

Java内存模型的特征

  • 原子性:对基本数据类型的访问读写是具备原子性的。
  • 可见性:通过主内存传递变量值的修改来实现可见性,多线程操作时的可见性由volatile,synchronized和final关键字保证。
  • 有序性:在同一线程中,所有操作都是有序的;在多线程中,通过volatile和synchronized关键字保证操作的有序性。

先行发生原则

  • 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  • 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
  • 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  • 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

Java与线程

  • 线程模型:Windows与Linux版Oracle JDK均采用1:1的线程模型实现,Solaris平台则可配置使用1:1或M:N线程模型。
  • 线程调度:抢占式线程调度,支持线程优先级(但不可靠)。

线程状态转换

  • 新建(New):创建后尚未启动的线程处于这种状态。
  • 运行(Runable):Runable包括了操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着CPU为它分配执行时间。
  • 无限期等待(Waiting):处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显式地唤醒。以下方法会让线程陷入无限期的等待状态:

    • 没有设置Timeout参数的Object.wait()方法。
    • 没有设置Timeout参数的Thread.join()方法。
    • LockSupport.park()方法。
  • 限期等待(Timed Waiting):处于这种状态的线程也不会被分配CPU执行时间,不过无须等待被其他线程显式地唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:

    • Thread.sleep()方法。
    • 设置了Timeout参数的Object.wait()方法。
    • 设置了Timeout参数的Thread.join()方法。
    • LockSupport.parkNanos()方法。
    • LockSupport.parkUntil()方法。
  • 阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;
  • 结束(Terminated):已终止线程的线程状态,线程已经结束执行。

转换关系如图
线程状态转换关系图

线程安全与锁优化

线程安全

线程安全的定义

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。

Java语言中的线程安全

  • 不可变:不可变的对象只要被正确构建出来(没有发生this引用逃逸的情况),那其外部的可见状态永远不会改变。例如:String,java.lang.Integer等。
  • 绝对线程安全:完全满足线程安全的定义。注意:Java API中大多数标注自己是线程安全的类,都不是绝对线程安全的!
  • 相对线程安全:保证对于对象单独的操作是线程安全的,但是对于一些特定顺序的连续调用,需要在调用端使用同步手段来保证调用的正确性。例如同时对一个Vector进行remove(),get()和size()操作,在使用get()时,之前在size()范围内的序号可能已经失效了。Java中大部分线程安全类都属于这种类型。
  • 线程兼容:对象本身不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。
  • 线程队里:无论是否采取了同步手段,都无法在多线程环境中并发使用的代码。如Thread.suspend()和Thread.resume()等。

线程安全的实现方法

  1. 互斥同步(悲观锁):只有一个线程能持有锁,其他线程在其释放锁之前只能挂起。如synchronized关键字和ReentrantLock。
  2. 非阻塞同步(乐观锁):不加锁,基于冲突检测的并发策略,假设操作没有冲突,如果有冲突就重试,直到成功为止。典型的方法是CAS操作,Java中JUC包中的原子数字类就是使用了CAS操作。
  3. 无同步方案:天生线程安全。

    • 可重入代码:可以在代码执行的任意时刻中断它,在控制权返回后,原来的程序不会出现任何错误。
    • 线程本地存储:将共享数据的代码限制在同一线程中执行。

同步:指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用。

锁优化

  1. 自旋锁:线程在申请锁时,如果锁已被其他线程持有,线程不会被挂起等待恢复,而是不断循环等待锁被释放。
  2. 锁消除:JIT会将一些代码上要求同步,但是检测到不可能存在共享数据竞争的锁进行消除。
  3. 锁粗化:在相近的代码或循环体中对同一对象反复加锁解锁时,虚拟机将会把加锁同步的返回扩展到整个操作序列的外部。
  4. 轻量级锁:在同步周期中不存在竞争时,使用CAS操作将对象的Mark Word更新为指向Lock Record(Mark Word的拷贝)的指针,以此代替互斥量;当同步周期中出现竞争时,轻量级锁失效,膨胀为重量级锁。
  5. 偏向锁:当锁第一次被一个线程持有时,将线程ID通过CAS操作写入Mark Word,此后该线程进入同步块将不再需要进行同步操作;若有另一线程尝试获取锁,则撤销偏向恢复到未锁定或轻量级锁的状态。
添加新评论