Skip to content

Latest commit

 

History

History
577 lines (566 loc) · 74.1 KB

深入理解JVM读书笔记.md

File metadata and controls

577 lines (566 loc) · 74.1 KB

深入理解JVM读书笔记

第二部分 自动内存管理

第二章 Java内存区域与内存溢出异常

  • 每个线程有自己的程序计数器
  • 程序计数器是唯一没有规定OOM的JVM内存区域
  • 执行本地方法时程序计数器值为空
  • 每个方法执行时都会在方法栈上创建一个栈帧,存储局部变量表(基本数据类型,实例的reference引用,字节码的returnAddress)、操作数栈、动态连接、方法出口等信息
  • 虚拟机栈和本地方法栈会stackoverflow,但如果jvm支持栈容量动态扩展,则当扩展到空间不足时会触发OOM(HotSpot不支持)
  • HotSpot虚拟机合并了本地方法栈与虚拟机栈
  • 堆仅用于分配对象和数组
  • 堆的垃圾回收机制以G1作为分界线(是否按新老分代回收)
  • 所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区 (Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率
  • Java对象有可能之后会不分配在堆上(逃逸分析与值类型支持)
  • 方法区:放置已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等
  • 运行时常量池可以动态改变,如String.intern()方法
  • 一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中
  • JDK1.4中加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据
  • 当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程
  • 内存分配的两种思路:
    1. 指针碰撞,用指针分隔使用过的内存和空闲内存,分配时仅仅移动指针即可,但需要保证内存空间规整,一般由GC是否带有空间压缩(Compact)整理来决定
    2. 空闲列表,允许内存空间不规整,具有全局视野。
  • 不同类型的垃圾回收算法搭配不同类型的内存分配方式
  • 对于指针碰撞而言,有可能出现多线程竞争同一区域的问题,此处可以通过同步处理来保证线程安全(CAS重试),亦或是使用前面提到的TLAB,即每个线程会在Java堆中预留一块区域进行对象缓存,只有当用完这块区域等待分配新区域时才会进行同步锁定。可通过-XX:+/-UseTLAB进行设定。
  • 虚拟机分配内存后需要将内存内容设置为0,启用TLAB时可以在分配TLAB时进行。这样当对象创建后字段可以不用设定初始值,使用默认值。
  • 创建对象的内存分配完成后,虚拟机会对对象头的信息做初始化,其中包括了GC分代信息,类信息,hashcode(延迟生成),偏向锁信息等。
  • HotSpot虚拟机中对象分为三部分:对象头,实例数据,对齐填充
  • 对于java数组,对象头中还需要存储其长度
  • 实例数据的存储顺序受虚拟机分配策略参数(-XX:FieldsAllocationStyle)和其在对象源码中定义的顺序影响,默认的分配策略中,相同宽度的字段会被分配到一起,如long和double,short和char,byte和boolean。如果+XX:CompactFields为true,则子类中较窄的变量可以插入父类变量的空隙中以节省少部分空间。
  • 对齐填充仅用于占位,因为HotSpot虚拟机管理内存要求对象起始地址为8字节的整数倍。
  • 栈中的本地变量表存储的对象reference有两种类型:
    1. 句柄,此时JVM需要单独句柄池空间,通过句柄在池中找到对象的真实内存地址,当对象地址变化时直接修改句柄池内的指针即可,池中也会存储实例对象的类对象信息
    2. 直接引用,reference直接对对象进行访问,可以节省一次指针定位的开销,但是需要考虑如何在内存中存储指向类对象信息的指针(放在实例对象信息中?)Hotspot主要使用这种方式。
  • -XX:+HeapDumpOnOutOf-MemoryError让内存溢出时Dump出当前的堆内存快照
  • 出现栈溢出时,如果是由于建立过多线程导致,则需要减少最大堆内存及栈的容量来允许更多线程的分配(比较反直觉)
  • DirectByteBuffer在分配内存过大时会抛出异常,但并没有向操作系统分配内存,而是通过计算得出内存无法分配而手动抛出异常。直接分配内存的地方在Unsafe::allocateMemory()
  • 如果OOM后dump出的内存没明显异常或者是文件很小,可以检查直接内存使用方面的原因,如使用了NIO等。

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

  • 衡量垃圾收集器的三大指标:内存占用,吞吐量,延迟
  • 引用计数法的原理简单,判定效率高,但有许多例外情况要考虑,如对象间的循环引用问题
  • 可达性分析算法:规定一系列的GC Roots,并维护对象间的引用链,若对象不可达gc root,则为不再使用的对象。固定作为GC Root的对象有:
    1. 虚拟机栈(栈帧的本地变量表)中引用的对象
    2. 方法区中类静态属性引用的对象
    3. 方法区中常量引用的对象,如字符串常量池中的对象
    4. 本地方法栈中引用的对象
    5. JVM内部的引用,如基本数据类型对应的class对象,一些常驻的异常对象,如NPE,以及系统类加载器
    6. 被同步锁(synchronized)持有的对象
    7. 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等(?)
    8. 在某些垃圾回收算法中会临时加入一些对象,比如以局部回收作为垃圾回收策略时,回收区域的对象有可能被其它区域的对象引用,此时应该把相关联的区域的对象也加入GC Roots进行可达性分析
  • 软引用:在即将抛出OOM前会将这些对象纳入回收范围内进行第二次回收,若此次还未有足够内存释放,则抛出OOM
  • 对象被回收前要经过两个标记阶段:
    1. 当GC Root不可达时被标记
    2. 当finalize()没必要执行(未被覆盖/已执行过一次)时被标记
    • 在第2阶段时对象有机会救回自己,如在finalize()方法中让this被引用链上的对象引用,但只有一次机会,因为下次不会再执行finalize()方法了
    • 当finalize()有必要执行时,会被放进一个队列中被低优先级线程调用,但不保证顺利执行,以防缓慢的程序逻辑拖慢队列进度。
  • 判断是否可以从方法区中卸载一个类时(不保证一定卸载),需要满足三个条件:
    1. 堆中没有此类的实例对象
    2. 该类对应的类加载器已经被回收,除了在某些可替换类加载器的场景下(OSGi、JSP的重加载)以外,此条件难以达成
    3. 该类对应的.class对象没有在任何地方被引用
  • HotSpot虚拟机与类回收相关的参数:
    • 控制是否对类进行回收:-Xnoclassgc
    • 查看类加载与类卸载信息:-verbose:class以及-XX:+TraceClass-Loading、-XX:+TraceClassUnLoading,Unloading版需要在FastDebug版虚拟机中使用
  • 分代回收的一个难点在于对象之间有可能存在跨代引用,虽然极少数(理论上被引用的对象大部分是共存亡),但如果出现这种情况,可以通过将老年代分区,并在新生代用Remember Set数据结构来保存其引用的老年代区域信息,便可避免对老年代进行全局扫描。但需要在对象引用关系发生改变(如赋值)时维护这块信息,增加运行时开销
  • GC类型:
    • Partial GC:
      • Minor/Young GC:发生在新生代
      • Major/Old GC:发生在老年代,目前只有CMS这么干(偶尔有资料用Major GC表示Full GC)
      • Mixed GC:新生代和部分老年代的回收,目前只有G1这么干
    • Full GC:针对整个Java堆和方法区进行回收
  • 新生代中的分区思想为半区回收,基于此思想及新生代对象特征(朝生夕灭)的回收算法为“标记-复制”算法,但实际上用以接收存活对象的空间不需要占去一半的空间,因此更优化的策略是Eden:From:To = 8:1:1,意味着90%的空间可以由新生代对象使用,当to空间不足时由额外空间担保(一般是老年代)
  • 老年代中使用“标记-整理”算法,以减少内存空间碎片,是一种移动式算法,直接将存活对象往内存空间一端移动,再清理掉边界外的所有内容。此算法移动对象时代价较大,需要Stop the World。若不采用移动式算法,如“标记-清除”算法,则需要将大内存空间的分配交给更为复杂的内存分配器,如基于空闲分配链表的分配器,但会增加内存访问的额外开销,使应用程序吞吐量下降。
  • Parallel Scavenge使用标记-整理算法保证高吞吐量
  • CMS使用标记-清除算法保证低延时,但在内存碎片过多以至于影响大对象分配时会用一次标记-整理算法
  • 查找引用链的过程可以与用户线程并发,但根节点枚举必须要STW,以保证根节点不会在枚举过程中发生引用变化导致分析不准确
  • JVM有办法知道哪些地方存放着对象引用,而不用扫描执行上下文或全局的引用位置去获知。在类加载时会计算出对象数据内的类型及偏移量,以及在即时编译时,会在SafePoint记录下栈和寄存器中哪些位置是引用。存放的数据结构叫OopMap,供收集器扫描时使用。
  • 到达Safepoint的线程才能暂停等待垃圾回收,Safepoint的设立准则是:是否具有让代码长时间运行的能力。比如方法调用、循环跳转、异常跳转,会触发安全点的设定。此处注意,当处理循环跳转时,由int引导的循环会被视为可数(Counted)循环,不需要等待太久,因此不会被插入安全点,由long引导的循环则会被插入。但在特殊情况下若前者含有缓慢执行代码,会导致垃圾回收时其它线程在safepoint自旋等待
  • 在垃圾回收开始前通知所有线程去安全点集合的两种思路:
    1. 抢先式中断:直接中断所有线程,如果该线程不在安全点上,就再让它继续跑到安全点。(几乎没有虚拟机用这个)
    2. 主动式中断:设置一个标志位,当线程运行到某些位置时会轮询这个标志位,一旦收到通知,便在最近的安全点上主动挂起。轮询标志的位置与安全点重合,除此之外还要在所有创建对象或分配内存的地方设置轮询位置,以检查是否将要执行垃圾回收,以防没有足够空间分配。
  • 当线程不处于运行状态时(如sleep,block)安全点不生效,此时需要安全区域(Safe Region)。安全区域中代码的引用关系不会发生变化,在此区域中的垃圾回收是安全的。
  • 当用户线程进入到安全区域时会标识自己进入了安全区域,此时垃圾收集会对该线程不与理会(不强求STW),当线程离开时会检查jvm是否完成了根节点枚举,或是其它需要暂停线程的操作,若未完成,则在安全区域内等待完成信号。
  • 关于Remembered Set的记录精度:
    1. 字长精度:直接记录跨代指针引用地址
    2. 对象精度:记录包含跨代指针引用的对象
    3. 卡精度:记录包含2中对象的内存区域,使用Card Table进行记录(HotSpot用字节数组来实现)。
  • 卡表的更新使用了基于AOP思想的“写屏障”技术,在每次赋值后都会触发写后屏障。
  • 多线程对卡表并发写时,若更新的对象都位于CPU缓存行中缓存的卡表内容所记录的内存中时,会导致缓存的伪共享问题。一个解决方案是在写卡表时先判断卡表是否已被写,若已则不写,此条件判断由-XX:+UseCondCardMark参数控制,用户可以在条件判断造成的损耗和CPU缓存伪共享的损耗之间权衡。
  • 与用户线程并发构建引用链时出现白色对象消失的两个条件(以三色法表达):
    1. 灰->白的直接/间接引用关系被全部断开
    2. 黑->白的引用关系被新增
    • 例如:在黑->灰->白的引用关系中,白色本应即将被标记,但如果在黑形成之后,断开灰->白,并连接黑->白,此时黑不会再被扫描,导致白不会被标记。
  • 针对破坏以上两个条件的方法为:
    1. 原始快照:当灰->白的关系被删除时,作下记录,等扫描结束后再假定这些关系没被删,将关系中的灰色节点当作root再做一次扫描。
    2. 增量更新:当黑->白的关系被新增时,作下记录,等扫描结束后将这些关系中的黑色节点当作root再做一次扫描。
    • G1、Shenandoah用原始快照做并发标记
    • CMS用增量更新做并发标记
    • 以上对引用关系修改的记录都是基于写屏障实现的
  • 经典新生代垃圾收集器:
    • Serial收集器:
      • 使用标记-复制算法
      • 占用额外内存最少
      • JVM客户端模式下默认的新生代收集器
      • 对单核场景友好(如一些客户端虚拟机)
    • ParNew收集器:
      • Serial的多线程并行版本(各垃圾收集线程之间并行)
      • 只有ParNew和Serial能与CMS搭配使用,前者为激活CMS后默认的新生代收集器
      • JDK9后ParNew和CMS只能互相搭配使用
      • 默认线程数与CPU核心数相等,亦可通过-XX:ParallelGCThreads参数配置
    • Parallel Scavenge收集器(吞吐量优先收集器):
      • 使用标记-复制算法
      • 没有采用最初的收集器框架,因此不能与框架内的老年代收集器共用,所谓的与SerialOld共用其实也是内部自己实现了一个功能一样的老年收集器,而非直接调用。
      • 主要关注用户线程吞吐量(CPU执行时间占比),而非停顿延时
      • -XX:MaxGCPauseMillis参数设置最大停顿时间
      • -XX:GCTimeRatio参数设置垃圾回收时间总占比(吞吐量),这个TimeRatio的计算方式有点怪。
      • Parallel Scavenge有自适应调节策略,通过-XX:+UseAdaptiveSizePolicy参数控制,能动态调节新生代大小、Eden与Survivor的比例、晋升老年代对象大小等参数以提供最合适的停顿时间或最大吞吐量
  • 经典老年代垃圾收集器:
    • Serial Old收集器:
      • Serial收集器的老年版本,使用标记-整理算法
      • 一般与Parallel Scavenge搭配使用,或在CMS运行失败(Concurrent Mode Failure)时使用
    • Parallel Old收集器:
      • Parallel Scavenge的老年版本,使用标记-整理算法
      • 亦关注吞吐量,运行多线程并发收集
      • 因为跳脱收集器框架,只能与Parallel Scavenge一起使用(高吞吐两兄弟)
    • CMS收集器:
      • 分为四个步骤:初始标记、并发标记、重新标记、并发清除
      • 初始标记和重新标记仍然需要Stop the World
      • 初始标记只标记GC Root能直接关联到的对象,很快的
      • 并发标记和重新标记是用于解决前面提到的黑白对象增量更新问题
      • 重新标记的停顿时间稍长
      • CMS默认启动的回收线程数是(处理器核心数量+3)/4,因此当核心少于4个时,影响用户线程吞吐量
      • 在并发标记和并发清理过程中用户线程在标记过程后产生的垃圾叫浮动垃圾
      • 如果浮动垃圾过多影响到并发的用户线程中对象分配,会导致CMS运行失效,导致Stop the World的Full GC发生,临时启用Serial Old收集器慢慢收尾
      • -XX:CMSInitiatingOccupancyFraction参数用于控制CMS触发的老年代内存占用百分比(不能像其它收集器一样等满了再收集,毕竟是与用户线程并发的,对留一些空间给对象)
      • 两个在JDK9后被废弃的与整顿内存碎片有关的参数:-XX:+UseCMS-CompactAtFullCollection,在不得不执行Full GC的时候整理碎片。-XX:CMSFullGCsBeforeCompaction,在若干次不整理空间的Full GC后下一次Full GC要整理碎片。
    • G1收集器:
      • 设计目标是实现可控的垃圾收集停顿时间(通过灵活控制最小回收单元Region的数量,以及计算每个Region的垃圾回收价值,维护优先级列表)
      • 不再将内存简单地划分为新生/老年代,或Eden/Survivor,而是将内存分块管理(Region),每个块都可以扮演其中的任意一个角色
      • Humongous区域用于存储大对象(超过Region内存一半大小)
      • Region大小可用-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且为2的N次幂
      • 大对象会被存放在N个连续的Humongous Region中,G1平时有点把他们当作老年代来看的
      • 每个region都维护一份Remembered Set,记录指向自己的Region并标记其中的Region所属的卡页范围,使用<Region地址, List<卡表索引>>的Map类结构来记录。相当于知道谁指向自己,并且也能追溯来源的内存位置(双向记录)
      • G1也有可能像CMS一样由于老对象回收赶不上新对象分配而发生Concurrent Mode Failure从而触发Full GC
      • 分为四个步骤:初始标记、并发标记、最终标记、筛选回收
      • 每个region都有两个指针(TAMS),用于标记并发回收垃圾时的新对象可以呆的地方(指针以上的内容不会被回收)
      • 只有并发标记是不用Stop the World的
      • 初始标记阶段在Minor GC中同步完成,无额外暂停时间
      • G1中并发标记阶段对引用关系变化的修改相比于CMS中更复杂,所以为异步执行(类似消息队列)
      • 并发标记时也会处理一些并发标记过程中有引用变动的对象
      • 最终标记则是处理并发标记没处理到的最后那一点引用变动对象
  • 低延迟垃圾收集器:
    • Shenandoah收集器:
      • 与G1有很多相似处,二者的修改可以反映到对方身上,比如Shenandoah给G1带来了并发失败时的多线程Full GC后备方案
      • 相比于G1,Shenandoah在最终的清理阶段也可以做到与用户线程并发
      • 也是基于Region设计,但不像G1一般区分新生代、老年代,且摒弃了Remembered Set,改为使用维护全局状态的Connection Matrix连接矩阵来记录跨区域引用关系,降低维护记忆集开销以及伪共享发生概率
      • Shenandoah的收集步骤分为9个阶段(.。。。)
        1. 初始标记:停顿时间与GC Root个数相关
        2. 并发标记:与G1一样
        3. 最终标记:与G1一样,将需要回收的Region收集进Collection Set回收集,会有小段停顿
        4. 并发清理:用于清理毫无存活对象的Region
        5. 并发回收:与前面所有收集器的核心区别,这一步将Region中存活对象复制到空Region中,通过读屏障和“Brooks Pointers”转发指针来解决用户在此期间的对象访问问题。这一步时长取决于回收集的大小
        6. 初始引用更新:这个阶段只是为了设置一个线程集合点,保证第5步中的各回收线程的对象复制已经完成,会产生很小的停顿
        7. 并发引用更新:真正执行引用更新操作,可以与用户线程并发,消耗时间与引用变化数量有关。这个步骤会对物理内存做线性搜索,将引用类型的旧值改为新值
        8. 最终引用更新:修正存在于GC Root中的引用,需要停顿,时长与GC Root数量有关。
        9. 并发清理:经过前面的步骤,回收集中的Region已无存活对象,此时再并发清理一次来回收这些空间,供新对象分配使用
      • Brooks Pointer实际上就是在对象的对象头前面再加上一个指针,正常情况下会指向对象自身地址,但在对象被复制后会指向新的对象地址,用户线程访问对象就用brooks pointer访问就好了,相当于做了一次指针转发。这么设计的风险在于当对象被复制后,但引用被更新前,若用户对对象进行写操作,新对象会无法接收到此信息。所以这里使用了CAS机制来保证对象访问正确性。
      • Shenandoah使用了大量的读屏障(第一个采用的收集器)和写屏障对对象访问的正确性做保障,会带来额外性能开销。针对这点可以优化的方向是将其改为“引用访问屏障”,只拦截对象中数据类型为引用类型的读写操作,可以省去对原生类型、对象比较、对象加锁等场景中读屏障带来的开销。
    • ZGC收集器:
      • ZGC也采用基于Region的内存区域管理策略(有的资料称其为Page)
      • ZGC的Region可以动态创建和销毁,以及按容量分为:
        1. 小Region:容量2MB,对象<256KB
        2. 中Region:容量32MB,对象>=256KB,<4MB
        3. 大Region:容量可变,但需为2MB整数倍,对象>4MB。
      • 大Region有可能容量比中Region小
      • 大Region只能放一个对象
      • 大Region不会被重分配(代价高)
      • 相比于Shenandoah用读屏障和转发指针的思路,ZGC另辟蹊径地使用了染色指针技术来解决并发整理阶段的对象访问问题
      • 染色指针会直接取对象地址中的4个比特位用于存储标记信息,因此有4TB的容量管理限制
      • 染色指针的三大优势:
        1. 当Region中的存活对象被移走后,这个Region可以立即被释放并使用,而不像Shenandoah一样需要等待引用更新后进行清理
        2. ZGC只使用读屏障
        3. 染色指针是一种可扩展的存储结构(当前64位Linux系统下的内存指针前18位仍未被使用)
      • 染色指针技术需要在操作系统层面做一些支持,以正确地识别物理内存地址,比如Solaris/SPARC平台可以通过虚拟地址掩码使机器指令忽略掉染色指针中的标志位,但x86-64平台则需要使用多重映射技术将不同标志位状态下的内存地址映射到同一个物理内存地址上。
      • ZGC收集器的四大步骤:
        1. 并发标记:前后也有初始标记和最终标记(需要短暂停顿),这一步的标记会直接反映在染色指针上,而不是对象上
        2. 并发预备重分配:ZGC每次都会扫描所有Region(因为没有记忆集),将要清理的Regin放入重分配集(Relocation set)。JDK12中ZGC开始支持的类卸载和弱引用处理也在这步完成
        3. 并发重分配:将重分配集中的存活对象复制到新的Region,并用一个转发表记录旧新对象的转向关系,染色指针会记录下这个对象是否位于重分配集中。如果用户线程并发访问这些对象,这次访问会被内存屏障截获并转发到新对象上,同时修正该引用的值。这被称为指针的“自愈”能力。Region被释放后转发表会依然存在,以保证自愈功能正常。
        4. 并发重映射:修正整个堆中指向重分配集中的所有对象的引用,这个步骤并不着急(得益于自愈能力),所以可以放在下一次回收的并发标记阶段,因为反正都要扫描所有对象的。
      • ZGC的劣势之一在于由于摒弃了记忆集和写屏障,因此不支持分代收集,当并发收集过程中产生的浮动垃圾(通常会被当作存活对象)量大时会让收集器难以喘息
      • ZGC与Parallel Scavenge支持NUMA内存分配(每个CPU核所在的裸晶都有自己管理的内存,相互之间通过InterConnection通道完成内存访问,会比访问自己内存慢很多),会优先尝试在请求线程当前所处的处理器的本地内存上分配对象
  • 垃圾收集器的选择:
    • 现实场景中垃圾回收器的选择主要出于以下3个方面的考虑:
      1. 吞吐量:对交互体验要求不高,但需要尽快算出结果的数据分析、科学计算类应用
      2. 延迟:对服务质量需要保证的SLA应用,不想见到事务超时
      3. 内存占用:运行硬件配置较低的客户端应用或嵌入式应用
    • 基于以上三点,考虑的范畴有:
      1. 运行应用的基础硬件设施,如硬件规格、系统架构、处理器数量、内存大小、操作系统等
      2. 使用JDK的发行商、版本、对应JDK规范的版本等
    • 小型应用可以使用不干活的Epsilon收集器。垃圾回收的本质是作为内存管理的一部分,假如应用本身不需要管理(比如运行时间极短或能保证退出前堆内存够用),那什么也不干也不失为一个好选择。
  • JDK9前后关于GC日志的查看参数发生过调整(进行了统一化)
  • HotSpot虚拟机的-XX:+PrintGCDetails参数,让虚拟机在发生垃圾收集行为时打印内存回收日志,并在进程退出的时候输出当前的内存各区域分配情况
  • 大对象是垃圾收集过程中的灾难,需要分配的连续空间大,做对象复制时的开销也大。所以可以用参数-XX:PretenureSizeThreshold设置大于此阈值的对象直接进入老年代(只对Serial和ParNew生效)。
  • 对象头中记录的年龄数会随着Minor GC的次数而增加,当增加到15岁时会被加入老年代,老年岁数可通过-XX:MaxTenuringThreshold参数设置
  • 如果Survivor空间中相同年龄的所有对象大小总和大于一半空间,则年龄大于等于这些对象的对象就直接进入老年代
  • 如果老年代剩余空间大于所有新生代对象占用空间大小,则这些回收是绝对安全的,否则虚拟机会查看担保参数-XX:HandlePromotionFailure值,若允许担保,则再检查老年代剩余的最大连续空间是否大于历次晋升到老年代的对象的平均大小,若大于,则尝试进行带风险的Minor GC,若小于或不允许担保,则进行一次Full GC。这个策略叫空间分配担保

第四章 虚拟机性能监控、故障处理工具

  • jps:虚拟机进程状况工具
    • 可以通过RMI协议查看远程JVM的进程状态
    • 不仅能显示虚拟机主类名,还可以通过参数显示jvm的启动参数、main方法接收的参数,以及主类命名/jar包路径
  • jstat:虚拟机统计信息监视工具
    • 亦具备本地、远程监视能力
    • 监视的内容主要分为三大块:类加载、垃圾收集、运行期编译状况
    • 可以指定时间间隔和运行次数
  • jinfo:Java配置信息工具
    • 可以实时查看虚拟机的参数,并做部分调整
    • 可以通过-sysprops参数把System.getProperties()的内容打印出来
  • jmap:Java内存映像工具
    • 用于生成堆转储快照(dump)
    • 还可以查询finalize执行队列、Java堆和方法区的详细信息,如空间使用率、使用的垃圾收集器等
    • 部分功能在Windows下使用受限
  • jhat:虚拟机堆转储快照分析工具
    • 与jmap后的dump文件搭配使用
    • 在localhost的7000端口以http格式显示分析结果
    • 一般不用
  • jstack:Java堆栈跟踪工具
    • 生成虚拟机当前时刻的线程快照
    • 一般用于解决线程长时间停顿问题,如线程间死锁、死循环、请求外部资源导致的长时间挂起等
  • 其它小工具:
    • javap:Java字节码分析工具
    • jdeps:Java类依赖性分析器
    • jdeprscan:JDK9后提供,搜索JAR包中使用了deprecated的类

第五章 调优案例分析与实战

  • 使用单JVM实例管理大内存时需要考虑的问题:
    1. 回收大块堆内存导致的长时间停顿(G1的增量回收可以缓解,但需要等ZGC与Shenandoah收集器成熟之后才能相对彻底解决)
    2. 大内存必须要64位虚拟机支持,但压缩指针、处理器缓存行容量等因素会导致其性能略低于32位虚拟机
    3. 必须保证应用足够稳定,大型单体应用如果发生堆内存溢出,几乎无法产生dump(太大了),或难以对dump进行分析,需要用到JMC这种生产环境运维工具
    4. 相同程序在64位机中消耗的内存要更大,由指针膨胀或数据类型对齐补白导致,默认开启的指针压缩可以缓解
  • 使用多JVM组建逻辑集群需要考虑的问题:
    1. 节点竞争全局资源,如磁盘资源(尤其并发写),容易导致IO异常
    2. 难以高效率利用某些资源,比如每个节点都会自己建立连接池,可能有的节点池满了有的还空着。可以使用集中式的JNDI解决,但会增加复杂性和额外性能开销
    3. 如果使用32位虚拟机做节点,仍受到内存限制,win平台每个进程只能用2GB,考虑到堆外内存开销,堆只能分1.5G。在某些linux平台(如Solaris)可以提升到3-4G,但也就到这里了
    4. 大量使用本地缓存(如HashMap)的节点应用,在集群中会造成重复缓存浪费内存,可以考虑使用集中式缓存
  • 需要关注的堆外内存情况:
    1. 直接内存:可通过-XX:MaxDirectMemorySize调整大小
    2. 线程堆栈:可通过-Xss调整大小
    3. Socket缓存区:每个Socket连接都有Receive和Send两个缓存区,连接多时可能占用大,无法分配内存时会抛出IOException:Too many open files异常
    4. JNI代码:会占用本地方法栈和本地内存
    5. 虚拟机和垃圾收集器:别忘了他们也要消耗内存的
  • JIT即时编译器可以在运行时通过热点代码信息收集来将一些调用达到一定次数的方法字节码编译为本地代码以提高运行效率,因此随着运行时间增长,代码会被编译得越来越彻底。但花费的编译时间会占用程序运行时间,可以用参数-Xint来强制使用解释器,或-Xcomp来强制使用编译器(解释器仍然会在编译无法进行的情况下介入)。

第三部分 虚拟机执行子系统

第六章 类文件结构

  • Class文件采用类似C语言中结构体一般的伪结构体来存储数据
  • Class文件中的数据只有两种类型:无符号数、表
  • 无符号数由u1,u2,u4,u8表示占用字节数
  • 表由_info作为结尾,Class文件本身亦可视作一张表
  • Class文件的内容顺序:
    • 0xCAFEBABE + 版本
    • 常量池:对常量池内元素的引用从1开始,因为0用来表示不引用
    • 访问标志:定义类信息,如是否为接口、是否为public、是否为abstract、是否为final等,目前使用了9/16个标志位
    • 类索引:父类索引与接口索引集合,存储指向常量池中类全限定名、父类全限定名、接口全限定名集合的索引
    • 字段表集合:类变量、实例变量,不包含方法内部的局部变量
    • 方法表集合:会出现重写后的父类方法信息,有可能出现编译器加进来的方法,如类构造器<clinit>()和实例构造器<init>()方法
    • 属性表集合
  • 字节码指令部分暂时跳过

第七章 虚拟机类加载机制

  • 类的生命周期:
    1. 加载
    2. 验证
    3. 准备
    4. 解析
    5. 初始化
    6. 使用
    7. 卸载
  • 验证、准备、解析三个部分统称为连接
  • 加载、验证、准备、初始化会按顺序地开始,而不是完成,因为实际过程中各个步骤的不同阶段会交叉执行。
  • 解析的时机有可能出现在初始化之后
  • 类的加载时机并未强制约束
  • 类的初始化时机(有且只有):
    1. 实例化对象、访问静态变量(常量字段在编译期已放入常量池)、调用静态方法
    2. 使用java.lang.reflect包进行反射调用时
    3. 子类被初始化时,要先递归地初始化父类
    4. 虚拟机启动时会先初始化指定的主类(含main方法的那个)
    5. 当使用JDK7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化(?)
    6. 当接口实现了default方法且接口的实现类发生初始化时,要先对接口进行初始化
  • 通过子类访问父类的静态字段时,无需触发子类初始化
  • 接口在初始化时不要求父类被初始化,在需要使用父接口(如访问其常量)时才会触发初始化
  • 类的加载步骤:
    1. 通过类的全限定名来获取此类的二进制字节流
    2. 将字节流中的静态存储结构转化为方法区的运行时数据结构
    3. 在内存中生成该类的java.lang.Class对象,作为此类的数据访问入口
  • 对于上述步骤1,获取二进制字节流的方式对于开发者来说有很大的发挥空间
  • 数组的加载:
    • 如果数组存储的类型是引用类型,则递归对其进行加载,并将数组标识在加载该类型的类加载器的类名称空间上(以作唯一性标识)
    • 如果数组存储的是基本类型,则将数组与引导类加载器关联
    • 数组类的可访问性与其存储的组件类型一致
  • 验证阶段:
    • 文件格式验证:防止字节码中出现恶意代码或无法正确将信息载入内存
    • 元数据验证:是否有父类(只有java.lang.Object没有),是否继承了不被允许继承的类(final类),是否实现了父类或接口类中要求实现的所有方法,字段名、方法名是否与父类冲突等
    • 字节码验证:最复杂的一步,会判断类型转换、方法跳转点、操作数栈类型是否正确等
    • 符号引用验证:发生在解析阶段中将符号引用转化为直接引用的时候,检查类是否缺少或禁止访问它依赖的某些外部资源
  • 准备阶段:
    • 为静态变量分配内存并设置初始值,初始值为0,正确的赋值会在初始化阶段进行(常量的初始值会一步到位设置好)
  • 解析阶段:
    • 将常量池内的符号引用替换为直接引用
    • 符号引用存储于Class文件中,与虚拟机内存布局无关
    • 直接引用是可以指向目标的指针、相对偏移量或能直接定位到目标的句柄,与虚拟机实现的内存布局相关
    • 虚拟机实现可以根据需要来判断解析发生的时间,比如类加载器加载类的时候,或是在符号引用被使用的时候
    • 解析阶段也会对类、方法或者字段的访问做可访问性检查
    • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行
  • 初始化阶段:
    • 真正开始执行类中编写的Java程序代码
    • 执行类构造器<clinit>()方法,此方法由编译器收集类中所有静态变量赋值和静态语句块中的语句合并而成,此部分代码顺序与Java代码中的编写顺序一致
    • <clinit>()不需要显式调用父类构造器,因为JVM会保证父类的<clinit>()方法会在子类的前面执行完毕
    • <clinit>()方法非必须生成,没静态代码就没有
    • 接口中没有静态语句块,但也会生成<clinit>()方法,因为有变量赋值操作,但其不要求父接口的构造器方法先执行完毕。接口的实现类在初始化时也不要求接口的构造器方法先执行完毕
    • JVM会保证多线程并发触发的<clinit>()方法被正确加锁,但如果其中的内容很长,有可能会导致其它线程阻塞(这个很隐蔽
    • 同一个类加载器下一个类只会初始化一次,因此当其中一个线程退出构造器方法后,其它被阻塞的进程也不会再进入了
  • 类与类加载器形成的二元组可以确定一个唯一的类
  • JDK9之前的三层类加载器,由父到子顺序为:
    1. 启动类加载器(Bootstrap Class Loader):
      • 负责加载存放在<JAVA_HOME>\lib目录或者被-Xbootclasspath参数指定的目录下的能够被识别的类
      • 启动类加载器无法直接被Java程序使用,如果想要用它来加载类,直接用null代替类加载器即可(参照ClassLoader.getClassLoader()方法)
    2. 扩展类加载器(Extension Class Loader):
      • 在sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现,可由Java程序直接调用使用
      • 负责加载<JAVA_HOME>\lib\ext中或者被java.ext.dirs系统变量指定的目录下的能够被识别的类
      • 在JDK9之后被模块化带来的天然扩展能力取代
    3. 应用程序类加载器(Application Class Loader):
      • 由sun.misc.Launcher$AppClassLoader实现
      • 是ClassLoader类中的getSystemClassLoader()方法返回的值,故又称为系统类加载器
      • 负责加载用户ClassPath中的所有类库,是程序中默认的类加载器
  • 双亲委派模型:
    • 当类加载器收到类加载请求,会先将请求递归向父类进行委派,直到最顶层的启动类加载器收到后,若发现无法完成加载(自己负责的范围内没这个类),则子类再自己尝试加载
    • 可以防止用户恶意构造系统自带类或因系统中出现过多同名类而导致混乱
    • 此模型不具有强制性约束。三层类加载器的想法是很好的:越基础的类越在顶层加载。但在特殊场景下会基础类会需要使用用户程序中的代码,比如由启动类加载器加载的JNDI服务,其存在目的是对资源进行查找和集中管理,便需要调用由其它厂商实现、部署在应用程序ClassPath下的JNDI服务提供者接口代码,但显然启动类加载器不认识这些代码
    • 解决上述问题的方式是使用线程上下文类加载器(不优雅的实现),可以让父类加载器委派子类加载器去完成类加载
    • 在JDK6时,JDK提供了java.util.ServiceLoader类,以META-INF/services中的配置信息,辅以责任链模式,才算给SPI的加载提供了一种相对合理的解决方式
    • OSGi通过打破双亲委派模型,使用网状结构来实现了模块化热部署(程序模块连同类加载器一同被替换),其类加载规则如下:
      1. 将以java.*开头的类,委派给父类加载器加载
      2. 否则,将委派列表名单内的类,委派给父类加载器加载
      3. 否则,将Import列表中的类,委派给Export这个类的Bundle的类加载器加载
      4. 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载
      5. 否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载
      6. 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载
      7. 否则,类查找失败。
  • JDK9后的模块化
    • 如果启用了模块化进行封装,模块就可以声明对其他模块的显式依赖,在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否完备,如有缺失那就直接启动失败,从而避免了很大一部分由于类型依赖而引发的运行时异常
    • 可配置的封装隔离机制还解决了原来类路径上跨JAR文件的public类型的可访问性问题(不再是所有地方都能访问到public代码了)。这种访问控制在类的加载过程(解析阶段)中完成
    • 扩展类加载器由平台类加载器(Platform Class Loader)所取代,因为JDK已经基于模块化构建,无需再完成可扩展需求
    • 新版JDK也取消了<JAVA_HOME>\jre目录,因为可以随时根据需要自己用命令打包出一个jre
    • 现在三大类加载器全部继承于jdk.internal.loader.BuiltinClassLoader
    • 现在当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载(破坏了双亲委派)

第八章 虚拟机字节码执行引擎(这一章我跳着看的)

  • 每一个栈帧都包括:局部变量表、操作数栈、动态连接、方法返回地址,和一些额外信息
  • 局部变量表大小和操作数栈深度会在编译期被计算出来并写入方法表的Code属性之中
  • 局部变量表的容量以变量槽作为最小单位,变量槽大小未受规范限制,但需要能放下boolean、byte、char、short、int、float、reference和returnAddress这8种数据类型(不超过32位)
  • reference用于表示一个对象实例的引用,具体实现方法不受规范限制,可以找到对象就行
  • returnAddress现在已经很少见了(古老的JVM会用于异常处理跳转,被现在的异常表取代)
  • 对于64位类型long和double,JVM会分配两个连续的变量槽空间,以高位对齐
  • 即便当前运行的代码已经超过了一个对象的作用域,该对象也有可能因为局部变量表中引用未被新对象覆盖而导致成为GC roots的一部分,从而不会被回收,所以有的代码才会把不使用的对象赋值为null
  • 两个栈帧之间有可能因共享局部变量表而出现重叠(下面的操作数栈与上面的局部变量表重叠)
  • 每个栈帧都包含指向运行时常量池中该栈帧所属方法的引用,为了支持方法调用过程中的动态连接
  • 非虚方法:在类加载阶段就能解析出的唯一方法,如静态方法、私有方法、实例构造器、父类方法、final方法
  • 虚方法:运行时才能确定具体运行的方法,Java对象的方法默认采用虚方法
  • 依据静态类型决定方法执行版本的分派动作,叫静态分派,如方法重载,在编译期便能确定调用的方法
  • Reflection是在模拟Java代码层次的调用,而MethodHandle是在模拟字节码层次的方法调用。前者会更加“重量级”,因为Reflection包含的信息更多,是一个方法在Java端的全面映像,有签名、描述符、方法属性表中各种属性的Java端表示方式、执行权限blabla,而后者只包含执行方法相关的信息

第四部分 程序编译与代码优化

第十章 前端编译与优化

  • 前端编译器:将代码转变为字节码的编译器(程序->抽象语法树/中间字节码)
  • Java的泛型实现方式为类型擦除式泛型(Type Erasure Generics),在编译后的字节码中泛型会被替换为原来的裸类型(Raw Type),并在相应地方插入了强制转型代码
  • 根据反编译字节码的结果,泛型集合定义时的类型会被删掉,但在其中的元素被访问时,会被进行一次强制类型转换,因此泛型集合不支持插入原始数据类型,因为原始数据类型与Object无法进行强制类型转换

第十一章 后端编译与优化

  • 解释器通常与编译器相辅相成工作,前者使得应用可以快速运行(不用编译),后者使得应用可以高效运行(直接执行本地机器代码),且前者可以作为后者的逃生门,在某些激进优化失败后作为后备方案
  • 解释器需要帮编译器收集性能监控信息,会影响解释执行的速度
  • JDK7服务器模式虚拟机中的默认编译策略:分层编译
    • 第0层:纯解释运行,不开启性能监控(Profiling)
    • 第1层:使用客户端编译器将字节码编译为本地代码运行,进行简单可靠的稳定优化,不开启性能监控
    • 第2层:仍然使用客户端编译器执行,仅开启方法及回边(在循环边界往回跳转)次数统计等有限的性能监控
    • 第3层:仍然使用客户端编译器执行,开启全部性能监控,在2的前提下还会收集分支跳转、虚方法调用版本等全部统计信息
    • 第4层:使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,会启用更多耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化
  • 实施分层编译后,解释器、客户端编译器、服务端编译器会同时工作,热点代码可能会被多次编译。用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间
  • 热点代码主要有两类:被多次调用的方法、被多次执行的循环体
  • 对于热点代码,编译的目标对象都是整个方法体
  • 热点代码会使用“栈上替换”的方式进行编译优化,即方法的栈帧还在栈上,方法就被替换了
  • 目前主流的热点探测判定方式有两种:
    • 基于采样(Sample Based)的热点探测:JVM会周期性检查各个线程的调用栈顶,如果发现某个方法经常出现在栈顶,则认定其为热点方法。此种探测方法实现简单高效,且易于获取方法的调用关系(将调用堆栈展开即可),但很难精确地确认一个方法的热度,容易受线程阻塞或别的外界因素影响而扰乱热点探测
    • 基于计数器(Counter Based)的热点探测:为每个方法(甚至代码块)建立计数器,统计其调用次数,超过一定阈值就认为它是热点方法。此种探测方法统计结果相对精准,但实现麻烦,且不能直接获取到方法的调用关系
  • HotSpot使用的是基于计数器的热点探测方法,其为每个方法准备了两类计数器:方法调用计数器和回边计数器。
  • 方法调用计数器默认阈值在客户端模式下是1500次,在服务端模式下是10000次,这个阈值可以通过虚拟机参数-XX:CompileThreshold来设置
  • 当方法调用时,JVM会先检查该方法是否有被即时编译后的版本,若有,则优先使用编译后的本地代码执行,若无,则将调用计数器加一,然后判断其数值与回边计数器之和是否超过阈值,若超过,则向即时编译器提交一个该方法的编译请求
  • 在默认设置下,方法调用计数器统计的不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time),进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足够长,程序中绝大部分方法都会被编译成本地代码。另外还可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒
  • 当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求,并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环(下一次回边时才使用本地代码)
  • 回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循 环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程
  • 默认条件下,编译是在后台进行的,等编译完成之后调用方法或触发回编才能使用这部分被优化的本地代码,否则程序继续以解释运行的方式执行。可以通过参数-XX:-BackgroundCompilation来禁止后台编译,后台编译被禁止后,当达到触发即时编译的条件时,执行线程向虚拟机提交编译请求以后将会一直阻塞等待,直到编译过程完成再开始执行编译器输出的本地代码
  • 编译优化的主要手段:
    • 方法内联:最重要的优化技术之一
      • 在进行虚方法内联时,需要使用继承关系分析技术来确定调用的方法版本,若方法版本全局唯一,则可进行守护内联(假定不会有别的版本),因为会存在风险(随时有新加载的类改变继承关系),属于激进型优化,需要保留备用方案(发现继承关系被改变时回退为解释运行或重新编译代码)。
      • 若无法确定虚方法代码版本,则使用内联缓存技术,在第一次进入方法体时根据接收者版本在方法体前缓存编译代码,并在下进入方法时做一次类型判断(在方法版本变化大的时候会由单态内联缓存退化为超多态内联缓存)
    • 逃逸分析:最前沿的优化技术之一
      • 逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度
      • 依据不同的逃逸程度可以采用的不同优化方法:
        • 栈上分配:如果确定对象不会逃出线程之外,那么在栈上分配对象内存可以使其随着栈帧出栈而被销毁,减少GC压力。支持方法逃逸,不能支持线程逃逸
        • 标量替换:将Java对象拆散,根据程序访问情况将使用到的成员变量恢复为原始数据类型进行访问。程序可以不用创建一个完整的对象,而只创建方法中使用到的成员变量,直接在栈上进行分配(很大机会被JVM分配到物理机器的高速寄存器中存储)。这种优化方法不允许对象逃出方法范围
        • 同步消除:如果能够确定线程中某个变量不会逃逸出线程,无法被其它线程访问,则对其的读写实施的同步措施可以完全消除掉
      • JDK7后服务端编译器默认开启,可以通过-XX:+DoEscapeAnalysis来手动开启,使用参数-XX:+PrintEscapeAnalysis查看分析结果。
      • 有了逃逸分析支持之后,可以通过参数-XX:+EliminateAllocations开启标量替换,使用+XX:+EliminateLocks开启同步消除,使用参数-XX:+PrintEliminateAllocations查看标量的替换情况
    • 公共子表达式消除:语言无关的经典优化之一
      • 除了公共子表达式外,还有可能在此基础上进行代数化简
    • 数组边界检查消除:语言相关的经典优化之一
      • JVM会在访问数组元素时进行进行上下界范围检查,当数组操作多时,会影响性能
      • 在编译期根据数据流分析判断数组的长度,以消除实际使用中的上下界检查
      • 隐式异常处理:检查是否为null的条件代码有可能会被优化为进程层面的Segment Fault异常处理(类似try catch NPE的形式,但不是try),由JVM注册。这样可以省去判空开销,但如果值真的为null,会涉及用户态和内核态的切换以处理异常,花费更大代价。JIT会根据运行期收到的监控信息来判断是否值得这么优化
    • 其它语言相关优化:自动装箱消除、安全点消除、消除反射等

第五部分 高效并发

第十二章 Java内存模型与线程

  • 缓存一致性:基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但在多路处理器场景下,每个处理器都有自己的高速缓存,对主内存的共享会导致缓存一致性问题,需要借助协议(MESI、MSI等)来保证缓存一致性
  • 指令重排:处理器可能会对输入代码做乱序执行优化,以充分利用处理器资源。计算后会把乱序执行的结果重组,保证结果一致。这个过程中程序中各个语句的计算顺序与输入代码的顺序不完全一致,因此如果某个计算任务依赖另一个任务的中间结果,其顺序性并不能靠代码顺序得到保证。JVM也有类似的指令重排序优化
  • Java内存模型规定了所有变量都存储在主内存,每条线程还有自己的工作内存(类比处理器高速缓存)。
  • 线程对变量的所有操作都必须在工作内存中进行,不同线程的工作内存无法互相访问。线程间变量值的传递需要通过主内存来完成
  • 线程的工作内存中会保存该线程使用到的变量的主内存副本,这不意味着一个对象会被完整地复制过来,只是其中的使用到的某些成员变量值有可能会被复制
  • volatile变量的两项特性:
    • 可见性:对此变量的修改会直接对其他所有线程可见。普通变量则需要经过主内存回写及其它线程的主内存读取后才可见。各个线程的工作内存中保存的volatile变量值可能会不一致,但每次使用前都会强制刷新,因此对执行引擎来说可以认为值一致。在运算结果不依赖变量当前值,以及不需要其它状态变量共同参与不变约束的场景下,volatile才线程安全。(反例:i++自增操作实际由读取、赋值、写入组成,只有读取能保证值是最新的)
    • 禁止指令重排优化:如果一个线程需要在完成一系列操作后通知另一个线程这个完成状态,则用于通知的状态变量需要设置成volatile,否则状态有可能在操作完成前就被设置(指令重排)。volatile使用内存屏障来保证指令顺序性。
  • 由于JVM对锁的消除和其它优化,volatile不一定比加锁(synchronized或juc包里的锁)快,但一般在能满足要求的前提下会优先使用volatile
  • volatile变量的读操作与普通变量几乎没有区别,但是写操作会慢上一些,因为需要在本地代码中插入内存屏障
  • 对于非volatlie的64位变量,如long、double,JVM规范不强制约束64位数据操作的原子性。long变量在32位虚拟机中存在非原子性访问的风险,JDK9针对性地增加了一个参数-XX:+AlwaysAtomicAccesses用于强制约束其操作的原子性。而针对double类型,现代中央处理器中一般都包含的专门用于处理浮点数据的浮点运算器(Floating Point Unit)能保证其原子性
  • 构造器中的this引用逃逸是一件很危险的事情,其他线程有可能通 过这个引用访问到“初始化了一半”的对象
  • 对于有序性,仅仅是针对线程内部观测表现出来的结果而言。如果在一个线程中观察另一个线程,则所有的操作都是无序的(由于指令重排序以及工作内存与主内存的同步延迟)
  • Happens-Before原则:Java内存下一些天然的先行发生关系(无须任何同步手段保障):
    • 程序次序规则:在一个线程内,按照程序的控制流(需要考虑分支、循环等)顺序,写在前面的操作先行发生于写在后面的操作
    • 管程锁定(Monitor Lock)规则:对于同一个锁,unlock操作先行发生于后面对同一个锁的lock操作
    • volatile变量规则:对volatile变量的写操作先行发生于后面对这个变量的读操作
    • 线程启动规则:Thread对象的start()方法先行发生于此线程中的每一个动作
    • 线程终止规则:线程中的所有操作先行发生于对线程的终止检测,如Thread.Join()方法和Thread.isAlive()方法
    • 线程中断规则:对线程interrupted()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,因此可以用该方法检测线程中断是否发生
    • 对象终结规则:一个对象的初始化完成(构造函数执行完毕)先行于其finalize()方法的开始
    • 传递性规则:如果操作A先行于操作B,操作B先行于操作C,则可以得出操作A先行于操作C的结论
  • 实现线程主要有3种方式:
    1. 内核线程实现(1:1)
      • 由内核完成线程切换和调度,并将各个线程的任务映射到各个处理器上
      • 每个内核线程可以视为内核的一个分身
      • 支持多线程的内核称为多线程内核
      • 程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口:轻量级进程,这便是通常意义上的线程
      • 每个轻量级进程都由一个内核线程支持,因此先支持内核线程才能支持轻量级进程
      • 每个轻量级进程都是一个独立调度单元,即使其中某个在系统调用中被阻塞也不会影响整个进程继续工作
      • 局限性在于,由于是内核线程支持,大部分操作都需要进行系统调用。并且轻量级进程会因此消耗一定的内核资源(如内核线程的栈空间),故一个系统支持轻量级进程的数量是有限的
    2. 用户线程实现(1:N)
      • 广义上一个线程只要不是内核线程,都可以认为是用户线程的一种(这么来看其实轻量级进程也属于,但它始终建立于内核之上,系统调用太多了,没有通常意义上用户线程的优点,所以不讲它)
      • 狭义上的用户线程是完全建立在用户空间的线程库上,系统内核无法感知到其存在与实现。线程的建立、同步、销毁全部在用户态中完成,不需要内核的帮助(程序实现得当甚至完全不需要切换到内核态)
      • 效率更高,支持规模更大的线程数量,部分高性能数据库的多线程就是这么实现的
      • 劣势在于由于没有内核支援,大部分线程操作需要由用户程序自己处理。创建、销毁、切换和调度都需要用户考虑。并且由于操作系统只把处理器资源分配到进程,类似阻塞处理和多处理器任务分配就变成了复杂的问题(甚至无法实现)
      • 一般的应用程序都不倾向使用用户线程,除了一些以高并发为卖点的语言,如Golang、Erlang等
    3. 用户线程+轻量级进程混合实现(N:M)
      • 用户线程仍然完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可支持大规模的用户线程并发
      • 操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁, 内核便能提供线程调度功能及处理器映射,以及处理用户线程的系统调用,大大降低整个进程被完全阻塞的风险
      • 许多UNIX系统都提供了这种模型实现,基于其之上的应用程序也相对容易实现这样的线程模型
  • JVM规范并不限定Java线程的实现
  • HotSpot中的每个线程都映射到了操作系统原生线程,中间没有额外的间接结构,所以HotSpot不会干涉调度(顶多设置优先级给操作系统提建议)
  • Solaris平台上的HotSpot虚拟机提供了实现N:M线程模型的参数设置
  • 线程调度的两种主要方式:
    1. 协同式线程调度
      • 线程把自己的工作执行完后才会进行线程切换,实现简单
      • 对于本线程来说是可知的,因此一般没有线程同步问题
      • 线程执行时间不可控,有可能阻塞各个程序
    2. 抢占式线程调度
      • Java线程的调度方式
      • 可以用Thread.yield()来主动让出执行时间,但无法主动索取
      • 可以通过设置线程优先级来向操作系统提建议,Java中共有10个线程优先级,越高则越容易被选中,但不保证
      • Java中的线程优先级与操作系统中的线程优先级数量不一定能对上,有可能会出现共享优先级的情况
  • Java线程的6种状态:
    • 新建(New):创建后尚未启动的线程
    • 可运行(Runnable):包括操作系统中的Running和Ready,此状态中的线程有可能在运行,也有可能在等待操作系统给它分配执行时间
    • 无限期等待(Waiting):不会被分配处理器时间,需要被其它线程显式唤醒。不设Timeout的Object.wait()和Thread.join(),LockSupport.park()都会导致线程进入这种状态
    • 限期等待(Timed Waiting):不会被分配处理器时间,但无需他人唤醒,自己会醒过来。Thread.sleep(),设置了Timeout的Object.wait()和Thread.join(),LockSupport.parkNanos()和parkUntil()方法可以让线程进入此状态
    • 阻塞(Blocked):与“等待状态”的区别是“阻塞状态”在等待着获取到 一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态
    • 结束(Terminated):线程结束执行
  • 内核线程的切换开销是来自于保护和恢复现场的成本
  • 用户线程的切换开销也同样存在,但在具体实现上存在很大的可发挥空间,比如协程(在内存中额外划分一块区域来模拟调用栈,亦称有栈协程)
  • 协程的主要优势是轻量,如果不显式设置-Xss或-XX:ThreadStackSize,64位Linux上HotSpot的线程栈容量默认是1MB,此外内核数据结构还会额外消耗16KB内存,而一个协程的栈通常在几百个字节到几KB之间
  • 协程的局限性在于需要在应用层面实现的内容(调用栈、调度器这些)特别多
  • 协程对于Java来说还有别的限制,比如像HotSpot虚拟机中Java调用栈与本地调用栈在一个地方,如果协程中调用了本地方法,还能否正常切换?以及遇到传统线程同步措施时协程该如何行动?譬如Kotlin的协程实现中,碰到synchronize关键字会导致整个线程挂起
  • Java正在尝试使用纤程来实现协程的功能

第十三章 线程安全与锁优化

  • 线程安全:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的
  • Java语言中的共享数据可分为五类:
    1. 不可变

      • final常量在不发生this引用逃逸的情况下被正确构建时,其对外部的可见状态永远不会改变
      • String、枚举类、Number的部分子类(如Long和Double等基本数值包装类型、BigInteger和BigDecimal等大数值类型)都是不可变的
    2. 绝对线程安全

      • 在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全,因为总是或多或少需要调用处额外添加一些协调代码(比如两个线程分别往vector里遍历和删除元素,可能出现遍历的下标被删除导致ArrayOutOfBoundException)
    3. 相对线程安全

      • 通常意义上的线程安全
      • 保证对对象的单次操作是线程安全的,但在一些特定顺序下的连续调用需要在调用方使用额外的同步手段
      • Java中大部分声称线程安全的类都属于这种类型
    4. 线程兼容

      • 对象本身并不是线程安全,但可以通过在调用端正确地使用同步手段来保证在并发环境下可用
      • Java中大部分类都是线程兼容的
    5. 线程对立

      • 不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码
      • Java中这样的代码是很少出现的
      • 通常这样的代码都是有害的,需要避免
      • 一个线程对立的例子是Thread类的suspend()和resume()方法。如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发进行的情况下,无论调用时是否进行了同步,目标线程都存在死锁风险——假如suspend()中断的线程就是即将要执行resume()的那个线程,那就一定产生死锁。因此suspend()和resume()方法都已被声明废弃。常见的线程对立的操作还有System.setIn()、Sytem.setOut()和System.runFinalizersOnExit()等
  • 线程安全的实现方法:
    1. 互斥同步
      • 最常见、最主要的一种手段,采取悲观策略(访问数据不做同步就一定会出问题)
      • 同步:保证在并发访问下,共享数据在同一时刻只被一个或一些(当使用信号量时)线程使用
      • 互斥:实现同步的手段,临界区(Critical Section)、互斥量(Mutex)、信号量(Semaphore)都是常见的实现方式
      • 互斥是方法、同步是目的
      • Java中最基本的方式是synchronized关键字,该关键字会在编译的代码块前后形成monitorenter和monitorexit字节码指令
      • monitorenter时会把对象监视器锁的计数器值+1,exit时会将计数器值-1。一旦值为0,锁便释放了。获取锁失败的线程会被阻塞直至锁被释放
      • 被synchronized修饰的同步块对同一条线程来说是可重入的
      • 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入(无法强制释放锁或让等待线程中断等待/超时退出),所以很重量级
      • Lock接口允许用户以非块结构来实现互斥同步
      • 可重入锁(ReentrantLock)是Lock接口最常见的一种实现
      • 可重入锁与synchronized功能类似,但增加了几个高级功能:
        • 等待可中断:当持有锁的线程长期不释放时,等待线程可以选择放弃等待,改为处理其它事情
        • 可实现公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁,默认不开启,但可以通过构造函数的布尔值参数开启,开启后会导致性能下降影响吞吐量。synchronized则是非公平(任意一个等待锁的线程都可以获得锁)
        • 锁可绑定多条件:一个ReentrantLock可以绑定多个Condition对象,多次调用newCondition()即可,以此实现分组唤醒线程的功能
      • 可重入锁与synchronized在性能上基本持平
      • Lock需要在finally块中释放锁,否则有可能因抛出异常导致锁不被释放
      • JVM更易对synchronized关键字进行优化
      • 互斥同步带来的性能损耗主要来自于线程的阻塞与唤醒
    2. 非阻塞同步
      • 采取基于冲突检测的乐观策略,无锁编程(不管风险,先进行操作,如果没有线程争用共享数据,则操作成功,否则使用补偿措施处理冲突,比如不断重试,直到共享数据没被竞争)
      • 此方案需要依赖硬件指令集的发展,以保证“操作”和“冲突检测”具有原子性,以硬件保证看起来多次的操作只通过一条处理器指令完成,如Test-and-Set、Fetch-and-Increment、Swap、Compare-and-Swap(CAS)、Load-Linked/Store-Conditional。最后两条是现代处理器新增的,且目的与功能类似
      • CAS指令需要有三个操作数:内存位置V(在Java中可以简单地理解为变量的内存地址)、旧的预期值A和准备设置的新值B。当V符合A时,才用B更新A的值,否则不执行更新。不管成功与否,都会返回V的旧值
      • JDK5后,CAS操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供,但Unsafe类在JDK9之前只允许启动类加载器加载的类使用,即Java自带类库,除非用户用反射技术来获取。JDK9之后VarHandle类里开放了面向用户的CAS操作
      • CAS语义存在逻辑漏洞(ABA问题):当变量被读为A,且准备赋值前也为A,不代表这其中其它线程就没改过它,比如先改为B又改为A。不过这个问题一般不是问题(不影响并发正确性)
    3. 无同步方案(有的代码天生就线程安全)
      1. 可重入代码
        • 又称纯代码,在代码执行的任何时刻中断它,转而执行其它代码,回来后原来的程序不会出现任何错误,也不影响结果正确,是线程安全代码的真子集。
        • 不依赖全局变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数传入,不调用非可重入方法等
        • 判断方式:只要输入相同数据,就能返回相同结果
      2. 线程本地存储
        • 如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题
        • 符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程限制在一个线程中消费完,其中最重要的一种应用实例就是经典Web交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多Web服务端应用都可以使用线程本地存储来解决线程安全问题
        • ThreadLocal类可以用来实现线程本地存储。每个Thread对象中都包含一个ThreadLocalMap对象,存储以ThreadLocal.threadLocalHashCode为键,本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量
  • 锁优化:
    1. 自旋锁与自适应自旋
      • 自旋:在进入阻塞状态前在原地多等一会(需要占用处理器时间),适用于多处理器并行场景
      • 自适应自旋:自旋的时间不固定,由前一次在同一个锁上的自旋时间及锁的拥有者状态决定(也有可能省略掉自旋过程)。随着运行时间增长以及性能监控信息完善,预测会越来越精准
    2. 锁消除
      • 对检测到的不可能存在共享数据竞争的锁进行消除
      • 主要判定依据来源于逃逸分析的数据支持
      • 如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行(比如单线程内的字符串累加)
    3. 锁粗化
      • 将同步块的作用范围限制得尽量小
      • 如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。此时JVM会将加锁同步的范围扩展(粗化)至整个操作序列的外部,只需一次加锁即可解决
    4. 轻量级锁
      • 轻量级是相对于基于操作系统中互斥量实现的传统锁而言
      • 对象头中的Mark Word是实现的关键(对于下面的偏向锁也一样)
      • Mark Word中有两个bit专门用来表示锁状态
      • 对对象上锁时,JVM会在当前线程的栈帧中建立“锁记录”空间,用于存储锁对象目前的Mark Word拷贝(一会要被用来干别的事情),之后用CAS操作将Mark Word更新为指向锁记录的指针。若更新成功,则锁标志位标记为已上锁,线程成功获取锁,若失败则先检查Mark Word是否指向自己的栈帧,如果是则说明早就成功了,直接进入同步块就行,否则说明锁已经被其它线程抢占了,此时锁会膨胀为重量级锁,对应的标志位也会更改。之后Mark Word的指针便指向重量级锁(互斥量)的指针,其它等待锁的线程需要被阻塞
      • 对对象解锁时,同样使用CAS操作进行,如果Mark Word仍然指向线程的锁记录,则直接用CAS将锁记录替换回来即可,如果成功则同步过程顺利完成,失败则说明有其它线程尝试过获取该锁(此时锁已膨胀为重量级),就要在释放锁的同时唤醒被挂起的线程
      • 轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则
      • 没竞争的时候轻量级锁用CAS操作避免了互斥量的开销,但如果有竞争,不仅要使用互斥量,还要加上CAS操作的开销,会比重量级锁开销更大
    5. 偏向锁
      • 目的是消除在无竞争情况下的同步原语(连CAS操作都懒得做了),提高程序运行性能
      • 锁会偏向于第一个获得它的线程,如果接下来锁一直没被其它线程获取,则该线程永远不需要再进行同步
      • 偏向锁在第一次被线程获取时,会将Mark Word的锁标记和偏向位打开,同时使用CAS操作将线程ID记录在上面。如果成功,则之后该线程进入此锁相关的同步块时,不用再进行任何同步操作(加锁、解锁、对Mark Word的更新操作等)
      • 一旦出现其它线程尝试获取锁状态,则根据锁对象目前是否被锁定来决定是否撤销偏向(设置偏向位),撤销后标志位恢复到未锁定或轻量级锁定,后续的同步操作按轻量级锁执行
      • 因为并不会像轻量级锁一样保持锁记录来记录Mark Word中原本存储的哈希值,一旦对象的哈希值被计算后,便不能使其进入偏向锁状态了。处于偏向锁状态的对象收到哈希值计算请求时也会立刻撤销偏向状态,膨胀为重量级锁
      • 重量级锁中对象头指向了重量级锁的位置,ObjectMonitor类里有字段记录原来的Mark Word
      • 在锁竞争激烈场景下可以通过-XX:-UseBiasedLocking参数禁止偏向锁优化来提高性能