深入理解 Java 虚拟机笔记

走近 Java

TODO

参考博客:
阿里技术专家耗时三天刷完《深入JVM虚拟机 第三版》是什么感觉:https://cloud.tencent.com/developer/article/2143636
郑雨迪(甲骨文实验室(Oracle Labs)的高级研究员)blog:https://www.infoq.cn/profile/BC8E672884EA01/publish

Java内存区域与内存溢出异常

运行时数据区域

程序计数器

线程私有空间,用于存放下一条指令所在单元地址的地方。每执行一条指令,程序计数器就会加1。

Java 虚拟机栈

线程私有空间,由许多栈帧组成,方法调用时会将当前方法对应栈栈帧压入虚拟机栈,每个栈帧包括局部变量表、操作数栈、动态链接、以及方法返回地址。

本地方法栈

线程私有空间,当线程调用本地方法时,会在本地方法栈中压入当前本地方法的栈帧。

Java 堆

所有线程共享的区域,用于存放大部分的对象实例及数组。

方法区

所有线程共享的区域,用于存放类信息、常量、静态变量、JIT 即时编译器编译后的机器代码等数据。JDK 1.8之前是永久代(持久代),JDK 1.8之后使用元空间替代永久代。

运行时常量池

运行时常量池是方法区的一部分,用于存放Class文件的常量池表。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,但是这部分内容也会被频繁使用,因为 Java 在1.4之后加入了 NIO,允许 Java 程序直接分配堆外内存,当使用的堆外内存突破物理机的总内存大小时,也会发生 OutOfMemoryError 异常。

HotSpot 虚拟机对象探秘

对象的创建

  1. 检查创建指令在常量池中是否有类的信息,并且检查类是否已经加载、解析、初始化,如果没有则执行类加载过程;
  2. 在 Java 堆中为对象分配内存空间;
    • 分配方式:指针碰撞或空闲列表,跟垃圾收集器相关
    • 线程安全:CAS 重试或本地线程分配缓冲(Thread Local Allocate Buffer,TLAB)
  3. 初始化内存空间,将其初始化为零值;
  4. 设置对象的对象头;
  5. 调用类的构造函数。

对象的内存布局

  1. 对象头,由以下三部分组成:
    • 运行时数据:哈希码、GC 年龄分代、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等;
    • 类型指针,即对象指向类型元数据的指针;
    • 数组长度,当对象是 Java 数组时会存储。
  2. 实例数据,在 Java 程序代码中所定义的各种类型字段内容,包含父类中的内容;
  3. 对齐填充,填充为8的倍数。

对象的访问和定位

  1. 通过句柄访问,Java 虚拟机栈的局部变量表中会保存一个 reference,指向 JVM 堆中的句柄池,句柄池中的句柄保存了对象实例数据的指针和对象类型数据的指针。优点:当对象频繁移动时,只需要修改句柄中的实例数据指针,不需要修改本地变量表中的 reference。

  1. 通过直接指针访问,Java 虚拟机栈的局部变量表中会保存一个 reference,直接指向对象的实例数据,对象的实例数据中会保存对象类型数据的指针。优点:访问对象速度快,只需要一次指针定位,HotSpot 虚拟机就是使用直接指针。

实战:OutOfMemoryError异常

Java堆溢出

  1. "Java.lang.OutOfMemoryError:Java heap space",堆中大量的对象未回收导致的。

虚拟机栈溢出和本地方法栈溢出

  1. "Java.lang.StackOverflowError",单线程情况下,新的栈帧内存无法分配,会抛出这个异常:
    • 栈帧太大,单个栈所需的内存超过-Xss设置的大小,Linux 228K;
    • 虚拟机栈容量太小时,一般是递归导致超过栈的深度导致的。
  2. "Java.lang.OutOfMemoryError": unable to create native thread, possibly out of memory or process/resource limits reached
    • 多线程情况下,由于达到了操作系统内存限制,无法再创建新线程时会抛出这异常。

方法区和运行时常量池溢出

  1. 使用 String.intern()创建大量字符串常量触发,在 JDK 1.6中,持久代内存溢出:"Java.lang.OutOfMemoryError:PermGen space",在 JDK 1.7及以后的版本中,"Java.lang.OutOfMemoryError:Java heap space",堆内存溢出
  2. 使用 CGLib 字节码增强技术来运行时生成大量的动态类,在 JDK 1.7 中触发"Java.lang.OutOfMemoryError:PermGen space"异常

本机直接内存溢出

  1. 在 JDK 1.4版本中,加入了 NIO,允许 Java程序直接申请操作系统内存,当大量使用 NIO 的 DirectByteBuffer 申请内存时,会触发本机直接内存溢出,通过Unsafe.allcateMemory()来触发"Java.lang.OutOfMemoryError"。如果内存溢出后产生的Dump 文件很小,操作系统内存占用又很大,程序中又直接或间接的使用了NIO,可以考虑重点检查一下直接内存方面的原因。

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

对象已死吗

引用计数算法

缺点:当对象间存在循环引用时,无法正确回收对象

可达性分析算法

通过一系列成为"GC ROOT"的根对象作为起始节点,根据引用关系向下搜索,当 GC Roots 到某个对象不可达时,证明这个对象是不可能被使用的。

可以作为 GC ROOT 的对象:

  1. 虚拟机栈中引用的对象;
  2. 方法区中类静态属性引用的对象;
  3. 方法区中常量引用的对象;
  4. 本地方法栈中 JNI 引用的对象;
  5. Java 虚拟机内部的引用;
  6. 所有被同步锁(synchronized)持有的对象;
  7. 反映 Java 虚拟机内部情况的一些对象(JMXBean、JVMTI中注册的回调、本地代码缓存)。

再谈引用

  1. 强引用(Strongly Reference),典型强引用的是 Object obj = new Object();只要强引用关系存在,对象永远不会被回收。
  2. 软引用(Soft Reference),用来描述一些还有用,但是非必须的对象,在系统将要发生内存溢出前,将会把只被软引用的对象列入回收范围进行第二次回收,如果这次回收之后仍没有足够的内存,才会抛出内存溢出异常。
  3. 弱引用(Weak Reference),用来描述那些非必须的对象,只被弱引用的对象在下一次垃圾回收中一定会被回收。
  4. 虚引用(Phantom Reference),最弱的一种引用关系,不会影响对象的生存时间,设置虚引用的目的只是为了在对象被回收时收到通知。

使用非强引用在很多情况下可以避免出现 OutOfMemoryError,但是过量使用也会对 GC 造成严重的影响,反而会降低系统性能。
在实际开发过程中,需要及时处理非强引用对象。

详情可参考:https://www.jianshu.com/p/13cfd631ad5e

生存还是死亡

对象在标记为不可达之后,并不会立即回收,虚拟机会判断是否有必要执行对象的 finalize() 方法,当对象重写了 finalize() 方法并且该方法未被执行过时,虚拟机会将对象放入 F-Queue 队列中,由另一个低优先级的 Finalizer 线程去执行队列中对象的 finalize() 方法,稍后收集器将会对 F-Queue中的对象进行二次标记,如果对象到 GC ROOT仍然不可达,将会被回收。

但是虚拟机不一定会等到 finalize() 方法结束后再回收它,是为了避免在 finalize() 方法中卡死了影响队列中其他的对象回收。
因为 finalize() 方法运行成本过高,且不易管控,在 Java 程序中,不推荐重写 finalize() 方法,需要使用的话,可以用 try-catch 代替。

回收方法区

主要是回收方法区中的废弃的常量和不再使用的类型,

  1. 废弃的常量,当常量池中的某个常量不再被任何 Java 对象引用,且虚拟机中也没有其他地方引用这个常量,当发生垃圾回收时,且收集器判断确有必要,将会把这个常量清理出常量池。

  2. 不再使用的类型,需要满足以下三个条件,

    • 该类型的所有实例都已被回收;
    • 该类型的类加载器已经被回收;
    • 该类型对应的 java.lang.class 对象没有在任何地方被引用。

    满足以上三个条件的类型,将被允许回收,但是并不是必然会被回收,可以通过 -Xnoclassgc 来控制是否对类型进行垃圾回收。在大量使用反射、动态代理、CGlib 等字节码框架,动态生成 JSP 等场景中,需要 Java 虚拟机具备类型卸载能力。

垃圾收集算法

分代收集理论

收集器应该将 Java 堆划分出不同的区域,然后根据回收对象的年龄,将其分配到不同的区域之中存储,垃圾收集器可以每次只回收某一个或者某些部分的区域。

  1. 弱分代假说:绝大多数对象是朝生夕灭的。
  2. 强分代假说:能熬过越多轮垃圾回收的对象,越难消亡。
  3. 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
    • 针对跨代引用,会在新生代中建立一个全局的记忆集(Remembered Set),标识出老年代中的哪一块区域会存在跨代引用,在发生 Minor GC 时,只需要把老年代中这一小块内存中的对象加入 GC ROOT 进行扫描就行。

标记-清除算法

首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记出存活的对象,统一回收所有未被标记的对象。
缺点:1.执行效率不稳定,如果 Java 堆中包含大量对象,而其中大部分是需要回收的,这时必须进行大量的标记和清除动作,导致标记和清除的过程执行效率降低;2.会使内存空间碎片化,标记、清除会产生大量不连续的内存碎片。

标记-复制算法

先标记出一个半区中所有存活的对象,然后将存活的对象复制到另一个半区的内存空间上,再将已使用过的内存空间一次清理掉,这样就不需要考虑内存空间碎片的问题,
缺点:1.将可用内存缩小为原来的一半;2.在对象存活率较高时需要进行较多的复制操作,效率会降低。

改进: “Appel”式回收,将内存空间分为一块较大的 Eden 区和两块较小的 Survivor 区,每次分配内存只使用 Eden 和其中一块 Survivor 区,发生垃圾回收时,将 Eden 和一块 Survivor 区中存活的对象一次性复制到另一块 Survivor 区中,然后清理掉 Eden 区和一块 Survivor 区。

分配担保机制:当 Survivor1 区没有足够的空间存放一次 Minor GC 之后存活对象时,这些对象将通过分配担保机制来提前进入老年代。

标记-整理算法

先标记所有存活的对象,再将所有存活的对象都想内存空间的一端移动,然后直接清理掉边界以外的内存。
缺点:1.在有大量对象存活的区域,需要大量的移动对象,必须全程暂停用户程序才能进行(Stop The World)

但如果像标记-清除算法那样不考虑移动和整理存活对象的话,会产生内存碎片,在每次对象分配内存的时候,需要通过一个分区空闲分配链表来解决内存分配问题。

这是一个两难的选择,移动对象,垃圾回收的时间会增加,不移动对象,内存分配时会更复杂,但是从全局来看,内存的分配和访问比垃圾回收更频繁,所以即使移动对象,整体的吞吐量还是上升的。

CMS收集器:在大多数时候使用标记-清除算法,当内存碎片率达到一定比例时,进行一次标记-整理算法收集。

HotSpot 的算法实现

根节点枚举

为了保证分析结果的准确性,根节点枚举必须在一个能保障一致性的快照中进行,所以根节点枚举一定会暂停用户线程(Stop The World)

为了提高效率,HotSpot 虚拟机使用一组称为 OopMap 的数据结构来保存执行上下文和全局引用的内存地址

OopMap:一个映射表,记录了栈上局部变量中哪些位置是引用类型,在 GC 时只需要读取 OopMap上的信息,是一种空间换时间的思想。

GC 发生时,用户线程运行到最近的安全点停下来,然后更新 OopMap,收集线程遍历每个栈帧的 OopMap,通过记录的被引用对象的内存地址,来找到这些 GC Roots。

OopMap详解:http://09itblog.site/?p=1089

安全点

强制要求必须执行到安全点后才能暂停用户线程。
产生安全点的指令:方法调用、循环跳转、异常跳转等

抢先式中断:垃圾收集发生时,系统首先会暂停所有用户线程,如果发现用户线程中断的地方不在安全点,就恢复这个线程的执行,直到它跑到安全点为止,现在几乎没有虚拟机采用抢先式中断。

主动式中断:虚拟机设置一个标志位,用户线程在执行时会不停的轮询这个标志位,一旦发现中断标志为真,就会在最近的安全点主动中断挂起。
轮询标志位的地方:安全点、创建对象时、需要在 Java 堆上分配内存时。

安全区域

解决垃圾回收时,线程处于 Sleep 或 Block 状态的问题。

安全区域是指能够确保在某一段代码中,引用关系不会发生变化,因此在这个区域中任意地方开始垃圾收集都是安全的。

当用户线程执行到安全区域时,会标志自己进入了安全区域,这时虚拟机如果垃圾回收,就不会管进入安全区域的线程,而这些线程如果在需要离开安全区域时,会检查虚拟机是否已经完成了根节点枚举(或者其他需要 STW 的的阶段),如果没完成,则继续在安全区域中等待。

记忆集与卡表

记忆集:用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。

卡表:记忆集的具体实现,每个记录都精确到一块内存区域,该内存区域内有对象含有跨代指针。

卡表中每一个元素被称为卡页,一个卡页中可能包含多个对象,只要有对象含有跨代指针,则该卡页的值设置为1,称为变脏,垃圾收集时,只需要扫描变脏的元素,就能轻易得出哪些卡页内存块中存在跨代引用指针,将其加入 GC Roots

写屏障

虚拟机通过写后屏障来维护卡表状态,卡表在高并发场景下会面临“伪共享”问题,为解决“伪共享”问题,解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏。

并发的可达性分析

并发扫描时,会出现把死亡对象标记为存活和把存活对象标记为死亡(对象消失)的问题,后者是非常致命的。

产生对象消失的条件:1.用户线程插入了一条或多条从黑色对象到白色对象的新引用;2.用户线程删除了全部从灰色对象到该白色对象的直接或间接引用。

两个解决方案:增量更新和原始快照。
增量更新(Incremental Update):当黑色对象插入新的指向白色对象的引用关系时,将这个新插入的引用记录下来,等并发扫描结束,再讲这些黑色对象为根,重新扫描一次。
原始快照(Snapshot At The Beginning):当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,等并发扫描结束,再用原始快照,以记录的灰色对象为根,重新扫描一次。这样可能会导致已死亡的对象不会被回收,从而产生浮动垃圾,但是相对于对象消失,这种结果是可以接受的。

经典垃圾收集器

Serial 收集器

Serial 收集器是一个单线程工作的收集器,进行垃圾收集时会暂停所有用户线程。新生代收集器为 Serial 收集器,老年代收集器为 Serial Old 收集器,新生代使用标记-复制算法,老年代使用标记-整理算法。由于没有线程切换开销,它是所有收集器中额外内存消耗最小的,目前是客户端模式下 HotSpot 虚拟机默认的新生代收集器。

ParNew 收集器

ParNew 收集器可以理解为 Serial 收集器的多线程并行版本,JDK 7之前首选的新生代收集器,因为除了 Serial收集器,只有 ParNew 收集器能和老年代 CMS 收集器搭配使用。在单核心处理器的环境中性能可能不如 Serial 收集器。

核心参数:

  1. -XX:SurvivorRatio:新生代中 Eden 区和 Survivor 区的比例。
  2. -XX:ParallelGCThreads:ParNew 收集器垃圾收集的线程数。
  3. -XX:PretenureSizeThreshold:新建的对象大于设置的值,会直接进入老年代。
  4. -XX:MaxTenuringThreshold:晋升老年代对象的大小。
  5. -XX:HandlePromotionFailure:是否允许空间分配担保失败。JDK 6之后默认开启
  6. -XX:+/-UseParNewGC:新生代使用 ParNew 收集器。
  7. -XX:+/-UseConcMarkSweepGC:老年代使用 CMS 收集器,新生代默认使用 ParNew 收集器。

Parallel Scavenge 收集器

Parallel Scavenge 收集器是一块新生代收集器,基于标记-复制算法实现,能够多线程并行垃圾收集,它的关注点和其它垃圾收集器不同,CMS 等收集器关注点是尽可能的缩短垃圾收集的停顿时间,Parallel Scavenge 收集器关注点是吞吐量,所以也被称作“吞吐量优先收集器”

核心参数:

  1. -XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间,单位毫秒。
  2. -XX:GCTimeRatio:设置吞吐量大小,范围0 ~ 100。
  3. -XX:PretenureSizeThreshold:晋升老年代对象的大小
  4. -XX:+/-UseAdaptiveSizePolicy:开启/关闭自适应调节策略,开启后,虚拟机根据系统运行情况收集性能监控信息,动态的调整 -Xmn,-XX:SurvivorRatio,-XX:PretenureSizeThreshold 等参数的值

Serial Old 收集器

Serial Old 收集器是 Serial 收集器的老年版本,它同样是一个单线程收集器,使用标记-整理算法。JDK5之前可以和 CMS 收集器搭配使用,做为CMS 收集器发生失败时的后背预案。

Parallel Old 收集器

Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,也支持多线程并行收集,使用标记-整理算法。在 JDK 6之后提供,和 Parallel Scavenge 收集器搭配使用。

CMS 收集器

CMS 收集器是一种以获取最短回收停顿时间为目标的收集器,基于标记-清除算法实现,运作的过程分为四个步骤:

  1. 初始标记:标记 GC Roots能直接关联到的对象,耗时较短,需要“Stop The World”。
  2. 并发标记:从 GC Roots 的直接关联对象开始遍历整个对象图的过程,耗时较长,但是可以和用户线程并发运行。
  3. 重新标记:为了修正并发标记期间,因为用户线程运行而导致标记产生变动的那一部分对象的标记记录,耗时较短,需要“Stop The World”。
  4. 并发清除:清理删除掉在标记阶段被判断为已经死亡的对象,耗时较长,但是可以和用户线程并发运行。

因为耗时较长的两个阶段:并发标记、并发清除,是可以和用户线程并发运行的,所以总体上来说,CMS 收集器的内存回收过程是和用户线程一起并发运行的。

CMS 收集器存在的三个缺点:

  1. CMS 收集器对处理器资源十分敏感,CMS 默认启动的回收线程数是(CPU 核心数 + 3)/ 4,在 CPU 数为4个或以上时,CMS 只占用不超过25%的 CPU 运算资源,但时候当 CPU 核心数低于4时,CMS 对用户程序的影响就可能变大。
  2. CMS 收集器无法处理浮动垃圾,可能会导致 Concurrent Mode Failure,由于在垃圾收集阶段用户线程还需要执行,需要预留一部分老年代内存空间以供并发收集阶段的用户线程使用,如果预留的老年代内存空间无法满足分配新对象的需求,会出现“并发失败(Concurrent Mode Failure),虚拟机将启动后备预案:暂停用户线程执行,临时启用 Serial Old 收集器来进行老年代的垃圾收集。
  3. CMS 基于标记-清除算法实现,会产生空间碎片,空间碎片过多会影响大对象的分配,大对象没有连续空间可以分配时,会提前触发一次 Full GC。

Concurrent Mode Failure 产生的原因及解决方法:

  1. CMS GC触发太晚。解决方法:调整 CMS 启动垃圾收集的阈值,参数 -XX:CMSInitiatingOccupancyFraction。
  2. 空间碎片太多。解决方法:开启空间碎片整理,并将碎片整理周期设置在合理范围,参数:-XX:+/-UseCMSCompactAtFullCollection(空间碎片整理),-XX:CMSFullGCsBeforeCompaction(在多少次 Full GC 后开始堆内存空间进行压缩整理)。
  3. 老年代的垃圾清理的速度赶不上进入老年代垃圾产生的速度,例如:晋升阈值过小;Survivor空间过小,导致溢出;Eden区过小,导致晋升速率提高;存在大对象。解决方法:调整堆大小,调整对象晋升老年代的大小,查看程序是否有大对象。

关于 Concurrent Mode Failure 可参考:https://blog.csdn.net/qian_348840260/article/details/88716970

Garbage First 收集器(G1收集器)

G1收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。

G1收集器是一款主要面向服务端应用的收集器。是一款能够建立“停顿时间模型”的收集器,被称为全功能收集器,目标是在低延迟的情况下获取尽可能高的吞吐量。

G1收集器把 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够针对扮演不同角色的 Region 采取不同的策略去处理。

Region 中有一种特殊的 Humongous Region,专门用来存储大对象,G1收集器认为大小超过一个 Region 容量一半的对象即是大对象。对于超过了一个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 中,G1收集器大部分情况下把 Humongous Region 当做老年代来处理。

G1收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,收集器去跟踪各个 Region 里面垃圾堆的“价值”大小(回收所获得的空间和所需要的时间的经验值),然后在后台维护一个优先级列表,每次根据用户所设定允许的收集停顿时间,优先处理回收价值最大的 Region,通过这种划分内存空间,以及记录优先级的方式,保证G1收集器能获得尽可能高的回收效率。

G1收集器实现的难点:

  1. 将 Java 堆划分为多个独立的 Region之后,如何解决跨 Region 引用的问题?使用记忆集避免整个堆作为 GC Roots扫描,每个 Region 都维护自己的记忆集,记录了别的 Region指向自己的指针,并标记这些指针分别在哪些卡页的范围内,记忆集为哈希结构,key 是别的 Region的起始地址,value 是一个集合,存储了卡表的索引号。由于划分了多个 Region,并且每个 Region 都需要维护自己的记忆集,所以 G1收集器比其他收集器需要占用更多的内存,大概是10% ~ 20%。
  2. 在并发标记阶段如何保证收集线程与用户线程互不干扰的工作?体现为两点,首先是如何保证标记阶段用户线程改变引用关系时,不会导致标记结果错误,该问题通过原始快照(STAB)算法来解决。第二个问题是如何解决垃圾回收过程中新对象分配的问题,G1收集器通过在每个 Region 维护两个名为 TAMS(Top at Mark Start)指针,把 Region 中一部分空间划分出来用户垃圾回收时新对象的分配,收集器默认在这个地址上的对象都是存活的,不纳入回收范围。和 CMS 的“Concurrent Mode Failure”一样,如果垃圾回收的速度跟不上对象创建的速度,G1收集器也会冻结所有用户线程,产生一次长时间 STW 的 Full GC,
  3. 如何建立可靠的停顿预测模型?G1收集器的停顿预测模型是以衰减均值为理论基础实现的,在垃圾收集过程中,G1收集器会记录每个 Region 的回收耗时、记忆集中的脏卡数量,并分析得出平均值、标准偏差等统计数据。衰减平均值更容易受最新数据的影响,所以衰减平均值能更准确的代表最近的评价状态。

G1收集器运行的过程:

  1. 初始标记:标记 GC Roots能直接到达的对象,并修改 TAMS 指针,在 Minor GC 时同步完成,耗时短,需要停顿。
  2. 并发标记:从 GC Roots 的直接对象开始扫描整个对象图的过程,耗时长,可并发,无需停顿。
  3. 最终标记:处理并发标记阶段因对象指针变化而需要处理的 SATB 记录,耗时短,需要停顿。
  4. 筛选回收:更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户设置的回收停顿时间,挑选出部分 Region 进行回收,将选中的 Region 中存活的对象复制到空的 Region中,再将旧的 Region 清空。因为设计对象的移动,这个阶段需要暂停用户线程,由多条收集线程并行完成的。

为什么在回收阶段要暂停用户线程?

  1. 设计者有考虑过,但实现起来比较复杂;
  2. G1收集器只是回收一部分 Region,停顿时间是用户可以控制的,所以并不迫切去实现;
  3. G1收集器并不只是面向低延迟,还需要保证吞吐量,所以选择暂停用户线程。

G1收集器和 CMS 收集器的优劣势比较:

  1. G1收集器可以指定最大停顿时间,内存布局是分 Region 布局,按受益动态确定回收集。
  2. CMS 收集器基于标记清除算法,G1收集器从整体来看是标记-整理算法,从局部来看是标记-复制算法。不会产生空间碎片。
  3. G1 的内存占用和程序允许时的额外执行负载要比 CMS 高。内存占用是因为G1每个 Region 都需要维护单独的记忆集,额外执行负载过高是因为 CMS 只需要
    通过写后屏障维护卡表,而 G1需要通过写前屏障和写后屏障来维护卡表。

注意点:

  • G1收集器停顿时间不能设置过小,会导致每次只回收一小部分 Region,从而导致垃圾回收的速度跟不上对象分配的速度,引发 Full GC。
  • 小内存情况下可以使用 CMS 收集器,超过 8GB 内存可以使用 G1 收集器。

低延迟垃圾收集器

垃圾收集的停顿时间不超过十毫秒

Shenandoah 收集器

只有 OpenJDK 才会包含,在 OracleJDK 中不存在。

ZGC 收集器

限制:ZGC没有分代回收,会产生大量的浮动垃圾,所以内存占用会比 G1 收集器大很多,适用于百 GB 或者 TB 级别的堆应用上。

垃圾收集器参数总结

内存分配与回收策略

对象优先在 Eden 区分配

大对象直接进入老年代

大小超过超过 -XX:PretenureSizeThreshold 阈值的对象将直接进入老年代

长期存活的对象将进入老年代

年龄超过 -XX:MaxTennuringThreshold 的对象将进入老年代

动态对象年龄判断

如果在 Survivor 区中相同年龄的所有对象大小总和大于 Survivor 区的一半,那年龄大于等于该年龄的对象可以直接进入老年代。

空间分配担保

在发生 Minor GC 之前,虚拟机会检查老年代的最大可用连续空间是否大于新生代对象的总和,如果不满足,虚拟机会查看 -XX:HandlePromotionFailure 参数,如果为true,则继续检查老年代的最大可用连续空间是否大于历次晋升老年代的新生代对象的平均大小,如果大于,则进行 Minor GC,否则改为进行一次 Full GC。

在 JDK6 Update24 之后,-XX:HandlePromotionFailure不再生效,虚拟机默认该值为true。

虚拟机性能监控与故障分析工具

JDK 的命令行工具

jps:虚拟机进程状况工具

jstat:虚拟机统计信息监视工具

jstat [option vmid [interval [count]]]

jinfo:Java 配置信息工具

jinfo [option] pid

jmap:Java 内存映像工具

jmap [option] vmid

用于生成dump文件,或查看 Java 堆详细信息等

jhat:虚拟机堆转储快照分析工具

一般不使用,可视化工具更好用

jstack:Java 堆栈跟踪工具

用于生成虚拟机当前时间的线程快照(threaddump文件)。

JDK 的可视化工具

JConsole:Java 监视与管理控制台

JVisualVM:多合一故障处理工具

安装插件后功能更强大

Java Mission Control:可持续在线的监控工具

调优案例分析与实战

TODO

类文件结构

TODO

虚拟机类加载机制

类加载的时机

《Java虚拟机规范》并没有强制约束什么情况下需要加载类,但是明确规定了有且只有6种情况下需要初始化类,那么加载动作肯定在初始化之前。

  1. 遇到 new、getstatic、pubstatic、invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段,场景有以下三种:
    • 使用 new 关键字实例化对象的时候。
    • 读取或设置一个类型的静态字段的时候(被 final 修饰的除外,会在编译器把结果放入常量池)。
    • 调用一个类型的静态方法的时候。
  2. 使用 java.lang.reflect 包的方法对类型进行反射调用,发现类还没进行初始化的时候。
  3. 当初始化类,发现其父类还没初始化的时候,初始化父类。初始化接口,但父接口没初始化的时候,不会初始化父接口。
  4. 虚拟机启用时,用户指定需要执行的主类(包含 main() 方法的类)还没初始化的时候
  5. JDK7 动态语言 java.lang.invoke.MethodHandle 解析出来的方法句柄指向的类还未初始化的时候
  6. JDK8 加入的接口默认方法,接口实现类初始化,发现接口还未初始化的时候

类加载的过程

加载

加载阶段,Java 虚拟机需要完成以下三件事:

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

其中第一点,不一定从Class文件中获取二进制字节流,也可以从其他地方获取:

  1. 通过 ZIP 压缩包中读取,后续演变为通过 JAR、EAR、WAR 包。
  2. 通过网络获取
  3. 运行时计算生成(动态代理技术)
  4. 其他文件生成,例如 JSP 文件
  5. 从数据库中获取
  6. 从加密文件中获取

非数组类型的加载阶段,开发人员可以通过定义自己的类加载器来完成加载。

验证

验证阶段是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合《 Java 虚拟机规范》的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机自身的安全。

验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重。

验证阶段大致会完成下面四个阶段的检验动作:

  1. 文件格式验证,验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。该验证阶段的主要目的是保证输入的字节流能正确的解析兵存储于方法区之内,这阶段的验证是基于二进制字节流进行的,验证通过之后,这段字节流会存储在 Java 虚拟机内存的方法区中,所以后面三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。

    该阶段的主要验证点:

    • 是否以魔数 0xCAFEBABE 开头
    • 主、次版本号是否在当前虚拟机接受范围之内
    • 常量池的常量中是否有不被支持的常量类型
    • 指向常量的各种索引值中是否有指向不存在的常量或者不符合类型的常量
    • CONSTANT_Utf8_info 型的常量中是否有不符合 UTF-8 编码的数据
    • Class 文件中各个部分及文件本身是否有被删除的或者附加的其他信息
  2. 元数据验证,第二阶段是对字节码描述的类的元数据信息进行语义分析,以保证其描述的信息符合《 Java 语言规范》的要求。

    该阶段的主要验证点:

    • 这个类是否需有父类(除了java.lang.Object之外,所有的类都应该有父类)
    • 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)
    • 如果这个类不是抽象类,是否实现了其父类或者接口之中所有要求实现的方法
    • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现了不符合规则的重载)
  3. 字节码验证,第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。

    该阶段的主要验证点:

    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作
    • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上
    • 保证方法体中的类型转换总是有效的

    由于数据流分析和控制流分析的高度复杂性,Java 虚拟机的设计团队,在 JDK 6 之后的 Javac 编译器和 Java 虚拟机里进行了一项联合优化,把尽可能多的校验放到 Javac编译器中进行。具体是在方法体 Code 属性的属性表中新增了一个 “StackMapTable” 属性,它描述了方法体所有的基本快开始时本地变量表和操作栈应有的状态,在字节码验证期间,Java 虚拟机就不需要在根据程序推到这些状态是否合法,只需检查 StackMapTable 属性中的记录是否合法即可。

  4. 符号引用验证,这个阶段发生在虚拟机将符号引用转换为直接引用的时候,这个转换动作将在解析阶段中发生。符号引用验证可以看作是对类自身以外的各类信息进行匹配性校验,通俗来说就是:该类是否缺少或者被禁止访问它以来的某些外部类、方法、字段等资源。

    该阶段的主要验证点:

    • 符号引用中通过字符串描述的全限定名是否能找到对应的类
    • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段
    • 符号引用中的类、方法、字段的可访问性(public、protected、private、package)是否可被当前类访问

准备

准备阶段是正式为类的静态变量分配内存并设置初始值(零值)的阶段,这些变量所使用的的内存都将在方法区中分配(JDK 7 及之前是永久代,之后存放在 Java堆中)

如果某些累的静态变量被final修饰,那么在准备阶段就会被初始化为它属性所指定的初始值。

解析

解析阶段是 Java 虚拟机将常量池内的符号引用转换为直接引用的过程。

  • 类或接口的解析

    假设当前代码所处的类为 D(即 D 类),如果要把一个从未解析过的符号引用 N 解析为一个类或者接口 C 的直接引用,有以下3个步骤:

    1. 如果C不是一个数组类型,那么虚拟机将会把 N 的全全限定名传递给 D 的类加载器去加载这个类 C。在加载过程中,可以需要加载这个类的父类、实现的接口等,一旦加载过程中出现任何异常,解析过程就宣告失败
    2. 如果 C 是一个数组类型,并且数组类型的元素类型为对象,那么将会按照第一点的规则加载数组元素类型。
    3. 如果上面两步没有出现任何异常,那么 C 在虚拟机中实际上已经成为一个有效的类或者接口了,这时候需要确认 D 是否具备对 C 的访问权限,如果没有访问权限,将抛出 java.lang.IllegalAccessError异常。
  • 字段解析

    要解析一个未被解析过的字段符号引用,需要先解析字段表内 class_index 中的 CONSTANT_Class_info 符号引用进行解析,也就是字段所属的类或者接口的符号引用,如果解析过程出现任何异常,都会导致字段符号引用解析失败,当解析成功后,用 C 标识字段所属的类或者接口,后续字段解析步骤如下:

    1. 如果 C 本身就包含简单名称和字段描述符与目标相匹配的字段,则返回这个字段的直接引用,查找结束
    2. 否则,如果 C 实现了接口,将会按照继承关系从下往上查找各个接口和它的父接口,如果包含了简单名称和字段描述符与目标相匹配的字段,则返回这个字段的直接引用,查找结束
    3. 否则,如果 C 不是 java.lang.Object 的话,将会按照继承关系从下往上查找它的父类,如果在父类中包含了简单名称和字段描述符与目标相匹配的字段,则返回这个字段的直接引用,查找结束
    4. 否则,查找失败,抛出 java.lang.NoSuchFieldError异常。
  • 类的方法解析

    方法解析的第一个步骤和字段解析一样,需要检查方法所属的类或者接口是否已被解析,后续步骤如下:

    1. 由于 Class 文件中,对于类的方法和接口的方法,符号引用的常量类型定义是分开的,因此如果在类的方法表中发现 class_index 中索引的 C 是个接口的话,那么久直接抛出 java.lang.IncompatibleClassChangeError 异常。
    2. 如果第一步通过,在类 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回,查找结束。
    3. 否则,在类 C 的父类中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回,查找结束。
    4. 否则,在类 C 实现的接口列表及他们的父接口中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回,说明类 C 是一个抽象类,查找结束,抛出 java.lang.AbstractMethodError 异常。(这一点需要理解,D类中,有符号引用N指向类C的方法,类C及其父类都没有该方法,接口中有,说明C是抽象类)
    5. 否则,查找失败,抛出 java.lang.NoSuchMethodError 异常。
  • 接口方法解析

    接口方法解析的第一个步骤和字段解析一样,需要检查方法所属的类或者接口是否已被解析,后续步骤如下:

    1. 与类的方法解析相反,如果在接口的方法表中发现 class_index 中索引的 C 是个类的话,那么久直接抛出 java.lang.IncompatibleClassChangeError 异常。
    2. 如果第一步通过,在接口 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
    3. 否则,在类 C 的父接口中查找,直到 java.lang.Object类,是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
    4. 对于规则3,由于 Java 接口允许多继承,当多个父接口中存在简单名称和描述符都与目标相匹配的方法,那将会从这多个方法中返回其中一个并查找结束,《Java 虚拟机规范》并没有规定需要返回哪一个接口方法,但是不同的发行商实现的 Javac 编译器可能会有更严格的校验。
    5. 否则,查找失败,抛出 java.lang.NoSuchMethodError 异常。

初始化

初始化阶段就是执行类构造器 <clinit>() 方法的过程, <clinit>()方法并不是由程序员在 Java 代码中直接编写的方法,它是由 Javac 编译器自动生成的。

  • <clinit>() 方法是由编译器自动收集类中所有的类变量的赋值动作和静态语句块(static{}块)中语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如下所示:

  • <clinit>() 方法与类的构造函数不同(即虚拟机视角中的实例构造函数 <init>() 方法),它不需要显式的调用父类构造器,Java 虚拟机会保证子类的 <clinit>() 方法执行前,父类的 <clinit>() 已经执行完毕。因此在 Java 虚拟机中第一个被执行的 <clinit>() 方法肯定是java.lang.Object的。

  • 由于父类的 <clinit>() 方法先执行,也意味着父类中定义的静态语句块要优先于子类的变量赋值操作,如下所示,字段 B 的值将会是 2 而不是 1。

  • <clinit>() 方法对于类或者接口来说并不是必需的,如果一个类中没有静态语句块,也没有对类变量赋值的操作,那么编译器可以不为这个类生成 <clinit>() 方法。

  • 接口中不能使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口和类一样会生成 <clinit>() 方法。但是与类不同的是,执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法,因为只有在父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法。

  • Java 虚拟机必须保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行完毕 <clinit>() 方法。如果在一个类的 <clinit>() 方法中有耗时很长的操作,那就可能造成多个进程①阻塞,在实际应用中这种阻塞往往是很隐蔽的。

类加载器

类与类加载器

比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个 Java 虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

这里的相等,包括代表类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance()方法的返回结果,也包括了使用 instanceof 关键字做对象所属关系判定等各种情况。

双亲委派模型

站在 Java 虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由 Java 语言实现,独立存在于虚拟机外部,并且全部继承自抽象类 java.lang.ClassLoader。

站在 Java 开发人员的角度来看,类加载器就应当划分得更细致一些。自 JDK 1.2 以来,Java一直保持着三层类加载器、双亲委派的类加载架构。

绝大多数 Java 程序都会使用到以下 3 个系统提供的类加载器来进行加载:

  1. 启动类加载器( Bootstrap Class Loader)

    • 负责加载存放在 <JAVA_HOME>\lib 目录,或者被 -Xbootclasspath 参数所指定路径存放的类库
    • 启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,那直接使用 null 代替即可
  2. 扩展类加载器( Extension Class Loader)

    • 负责加载 <JAVA_HOME>\lib\ext 目录中,或者被 java.ext.dirs 系统变量多指定路径中的类库
  3. 应用程序类加载器( Application Class Loader)

    • 应用程序类加载器是 ClassLoader 类中的 getSystemClassLoader() 方法的返回值,有时也称为“系统类加载器”
    • 负责加载用户类路径( ClassPath)上所有的类库
    • 如果引用系统中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

双亲委派模型要求除顶层的启动类加载器外,所有的类加载器都应有自己的父类加载器,不过这并不是继承,而是组合关系

双亲委派模型的工作过程:

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

使用双亲委派模型来组织类加载器之间的关系,有个好处就是 Java 中的类随着它的类加载器一起具备了一种带有优先级的关系。例如类 java.lang.Object,它存放于 rt.jar中,无论哪个类要加载它最终都是委派给处于模型顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都能保证是同一个类。反之如果没有双亲委派模型,都由各个类加载器自行去加载的话,如果用户也编写了一个 java.lang.Object 类,并放在 ClassPath 中,那系统中可能会存在多个 java.lang.Object 类,应用程序将一片混乱。

以上代码逻辑:先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的 loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器。加入父类加载器加载失败,抛出 ClassNotFoundException 异常的话,才调用 findClass() 方法尝试进行加载。

破坏双亲委派模型

Java 模块化(JDK9)出现之前,双亲委派模型主要出现过 3 次较大规模“被破坏”的情况。

  1. 第一次被破坏发生在双亲委派模型出现之前————即 JDK1.2 之前的远古时代,当时已经有类加载器,并无双亲委派模型,为了解决这个问题,兼容已有代码,在 JDK1.2 之后的 java.lang.ClassLoader 中添加了一个新的 protected 方法 findClass(),并引导用户编写类加载逻辑是尽可能去重写这个方法,而不是在 loadClass()中编写代码,这样既不影响用户按自己的意愿去加载类,又可以保护双亲委派模型不会被破坏。
  2. 第二次被破坏是因为,当基础类型又需要调用回用户的代码时,将会破坏双亲委派模型,例如 JNDI 调用 SPI 时,为了解决这个问题,Java 设计团队引入一个并不太优雅的设计:线程上下文类加载器( Thread Context ClassLoader)。这个类加载器可以通过 Thread 类的 setContextClassLoader() 方法进行设置,如果创建线程时未设置,它将会从父线程继承,如果全局都未设置的话,那么这个类加载器默认就是应用程序类加载器。在JDK6时提供了 java.util.ServiceLoader 类来加载 SPI。
  3. 第三次被破坏是由于用户对程序的动态性的追求而导致的,例如:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。

开发者如果破坏双亲委派模型呢?

  • 使用SPI机制;
  • 自定义类继承 ClassLoader,作为自定义类加载器,重写 loadClass() 方法,不让它执行双亲委派逻辑,从而打破双亲委派模型。但是遇到自定义类加载器和核心类重名或者篡改核心类内容,JVM 会使用沙箱安全机制,保护核心类,防止打破双亲委派机制,防篡改,如果重名的话就报异常。

沙箱安全机制是 Java 安全模型的核心,它严格限制运行在 JVM 上面的代码对系统资源的访问。系统资源包括:CPU、内存、网络、文件系统等。

参考资料:

双亲委派模型的破坏:https://blog.csdn.net/qq_41863849/article/details/124365479

JAVA之JNDI:https://www.cnblogs.com/0x3e-time/p/17023592.html

Java SPI机制从原理到实战:https://zhuanlan.zhihu.com/p/441812213

JVM 之沙箱安全机制:https://blog.csdn.net/qq_39126517/article/details/123372281

JVM系列(四):沙箱安全机制笔记:https://cloud.tencent.com/developer/article/2009649

了解JNDI:Java中的命名和目录服务接口:https://blog.csdn.net/pengjun_ge/article/details/131469021

Java模块化系统

虚拟机字节码执行引擎

方法调用

解析

分派

  1. 静态分派

    静态分派是指依赖静态类型来决定被调用的方法的分派动作。静态分派的最典型应用表现就是方法重载(Overload)。

    在一个方法有多个重载版本的情况下,往往只能确定一个相对更合适的版本,例如 sayHello(char arg) sayHello(int arg) 和 sayHello(long arg),当使用 sayHello('a') 调用时,会调用 sayHello(char arg),当 sayHello(char arg) 注释时,会调用 sayHello(int arg),当 sayHello(int arg) 也被注释时,会调用 sayHello(long arg)。

  2. 动态分派

    动态分派是指在运行时根据实际类型来确定被调用的方法的分派动作。动态分派的最典型应用表现就是方法重写(Override)。

    动态分派是通过虚方法调用指令(invokevirtual)来实现的(元数据查找):

    • 找到操作数栈定的第一个元素所指向的对象的实际类型,记作 C
    • 如果在类型 C 中找到了与常量中的描述符和简单名称都相同的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,不通过则返回 java.lang.IllegalAccessError异常。
    • 否则,按照继承关系从下往上依次对 C 的各个父类进行查找
    • 如果始终找不到合适的方法,则抛出 java.lang.AbstractMethodError异常。
  3. 单分派和多分派

静态分派是多分派,动态分派是单分派

  1. 虚拟机动态分派的实现

动态分派是非常频繁的动作,因此一般不会如此频繁的去搜索元数据,而是在类的方法区中建立一个虚方法表(Virtual Methond Table,也称vtable),接口有接口方法表(Interface Methond Table,也称itable),使用虚方法索引来替代元数据查找以提高性能。

虚方法表中存放着各个方法的实际入口,如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现地址的入口地址。

注意:直接查找 itable 和 vtable 已经是最慢的分派了,只在解释执行状态时使用,在即时编译时有更多优化措施。

类加载及执行子系统的案例与实战

TODO

前端编译与优化

  • 前端编译器:JDK 的 Javac、EclipseJDT的增量式编译器
  • 即时编译器(JIT 编译器,Just In Time Compiler):HotSpot 虚拟机的 C1、C2 编译器,Graal 编译器
  • 提前编译器(AOT 编译器,A head Of Time Compiler):JDK 的 Jaotc、GNU Compiler for the Java (GCJ)、Excelsior JET。

后端编译与优化

即时编译器

  • 为何 HotSpot 虚拟机要使用解释器与编译器并存的架构?

    解释器和编译器两者各有优势,在整个 Java 虚拟机执行的架构里,解释器和编译器经常是相辅相成地配合工作:

    1. 当程序需要循序启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译为本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。
    2. 当程序运行环境中内存资源限制较大,可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。
    3. 解释器还可以作为编译器激进优化时后备的“逃生门”,让编译器根据概率选择一些不能保证所有情况都正确,但是大多数时候能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类以后,类型继承结构出现变化、出现“罕见陷阱”时可以通过逆优化退回到解释状态继续执行。(HotSpot虚拟机有时也会采用客户端编译器充当“逃生门”角色)
  • 为何 HotSpot 虚拟机要实现两个(或三个)不同的即时编译器?

    • 其中两个编译器存在已久,分别是客户端编译器(C1)和服务端编译器(C2),第三个编译器是在 JDK 10 时才出现的、长期目标是代替 C2 的 Graal 编译器。
    • 在分层编译出现之前,HotSpot 虚拟机通常采用解释器与其中一个编译器搭配的方式来工作,具体选择哪个编译器由虚拟机的允许模式来决定,客户端模式使用 C1 编译器,服务端模式使用 C2 编译器。这种方式称为混合模式,也可以通过“-Xcomp”强制使虚拟机运行于“编译模式”
    • 在 JDK 6 时,HotSpot 虚拟机初步实现了分层编译功能,解释器、客户端编译器、服务端编译器可以同时工作,热点代码都可以会被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器获取更好的编译质量,在解释执行时也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间。
  • 程序何时可使用解释器执行?何时使用编译器执行?

    在分层编译中,根据编译器编译、优化的规模和耗时,划分出不同的编译层次,其中包括:

    • 第 0 层。程序纯解释执行,并且解释器不开启性能监控功能。
    • 第 1 层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
    • 第 2 层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
    • 第 3 层。仍然使用客户端编译器执行,开启全部性能监控,除第 2 层达的统计信息外,还会增加如分支跳转、虚方法调用版本等全部的统计信息。
    • 第 4 层。使用服务端编译器将字节码编译为本地代码来运行,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一个不可靠的激进优化。

深入浅出 Java 10 的实验性 JIT 编译器 Graal:https://www.infoq.cn/article/java-10-jit-compiler-graal

  • 哪些程序代码会被编译成本地代码?如何编译本地代码?

    被频繁调用的方法和循环体会被优先编译为本地代码。HotSpot 虚拟机使用基于计数器的热点探测来判断方法或者循环体是否为热点代码。当一个方法或者循环体被调用多次后,就会被认为是热点代码,并被优化为本地代码。

    计数器分为方法调用计数器和回边计数器,方法调用计数器在运行一定时间后,计数值会衰减为一半,这段时间称为半衰周期。


  • 如何从外部观察到即时编译器的编译过程和编译结果?

    部分运行参数需要 FastDebug 或 SlowDebug 优化级别的 HotSpot 虚拟机才能够支持,Product 级别的虚拟机无法使用这部分参数。
    即一般人观察不到,需要自己动手编译 JDK,或者非官方编译版本

解释器与编译器

编译对象与触发条件

编译过程

实战:查看及分析即时编译结果

编译器优化技术

四种代表性的优化技术:

  • 最重要的优化技术之一:方法内联。

    方法内联时编译器最重要的优化手段,被称为优化之母,因为除了消除方法调用的成本外,它更重要的意义是为其他优化手段建立良好的基础。

  • 最前沿的优化技术之一:逃逸分析。

    分析对象动态作用域,当一个对象在方法里面被定义后,可能会被外部方法引用,称为方法逃逸,或者被外部线程引用,称为线程逃逸,否则就是从不逃逸。

    • 栈上分配(当一个对象不会逃逸到线程外时)
    • 标量替换(当一个对象不会逃逸到方法外时)
    • 同步消除(锁消除)(当一个对象不会逃逸到线程外时)
  • 语言无关的经典优化技术之一:公共子表达式消除。

    int d = (c * b) * 12 + a + (a + b * c)
    int d = (c * b) * 13 + a + a

  • 语言相关的经典优化技术之一:数组边界检查消除。

    如果编译器在编译器通过数据流分析可以判断数组不会越界,那么就可以省略掉数组边界检查,如:

    • 数组下标是常量,foo[3],foo.length 大于等于3。
    • 循环一个list,访问list中的元素,下标在[0, list.length]之间。

Java 内存模型与线程

Java 内存模型

主内存与工作内存

Java 内存模型主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。

Java 内存模型规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读取主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

内存间交互操作

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

如果要把一个变量从主内存拷贝到工作内存中,那就要按顺序执行 read 和 load 操作,Java 内存模型只要求这两个动作是按顺序执行,但不要求是连续执行。除此之外,Java 内存模型还规定了在执行上述 8 种操作时必须满足如下规则:

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

对于 volatile 型变量的特殊规则

当一个变量被定义成 volatile 之后,它将具有两项特性:

  • 保证此变量对所有线程的可见性
  • 禁止指令重排序优化

在进行 read、load、use、assign、store 和 write 操作时需要满足如下规则:

  • 在工作内存中,每次使用 volatile 变量前都必须先从主内存中刷新最新的值,用于保证能看见其他线程对该变量所做的修改。
  • 在工作内存中,每次修改 volatie 变量后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对该变量所做的修改。
  • volatile 修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同。

由于volatile变量只能保证可见性,如果符合以下两条规则才能保证原子性:

  • 变量的运算结果并不依赖变量当前的值,或者能够确保只有单一线程修改变量的值
  • 变量不需要与其他的状态变量共同参与不变约束(不需要与其他变量一起参与判断,例如 if(a&&b) )
  • 而且,访问变量时,没有其他原因需要加锁

参考文章:

深入理解 volatile 关键字:https://cloud.tencent.com/developer/article/2035951

原子性、可见性与有序性

  1. 原子性

    由Java内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store 和 write。我们大致可以认为,基本数据类型的访问、读写都是具备原子性的。
    如果一个应用场景需要一个更大范围的原子性保证,Java 内存模型还提供了 lock 和 unlock 操作来满足这种需求,虚拟机未直接把 lock、unlock开放给用户使用,而是提供了更高层次的 monitorenter 和 monitorexit 来隐式地使用者两个操作。这两个字节码指令反映到 Java 代码中就是同步块————synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。

  2. 可见性

    Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性。Java 中有三个关键字能保证多线程下的可见性,volatile、synchronized 和 final。
    volatile通过它的特殊规则来保证可见性。synchronized 通过 “对一个变量执行 unlock 操作前,必须先把此变量同步回主内存中” 这条规则获得的。final 关键字的可见性是指:被 final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把 “ this ” 引用传递出去,那么在其他线程中就能看见 final 字段的值。

  3. 有序性

    Java 语言提供了 volatile 和 synchronized 关键字拉保证有序性,volatile 是通过禁止指令重排序来保证有序性,而 synchronized 是通过 “一个变量在同一时刻只允许一个线程对其进行 lock 操作” 这条规则来获得的。

先行发生原则(Happens-Before)

  • 程序次第规则

    在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。

  • 管程锁定规则

    一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。

  • volatile 变量规则

    对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。

  • 线程启动规则

    Thread 对象的 start() 方法先行发生于此线程的每一个动作。

  • 线程终止规则

    线程中的所有操作都先行发生于对此线程的终止检测。

  • 线程中断规则

    对线程 interrupt() 方法的调用先行发生于先行发生于被中断线程的代码检测到中断事件的发生。

  • 对象终结规则

    一个对象的初始化完成(构造函数执行完成)先行发生于它的 finalize() 方法的开始。

  • 传递性

    如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么就可以得出操作 A 先行发生于操作 C 的结论。

Java 与线程

线程的实现

  1. 内核线程的实现

    使用内核线程实现的方式也被称为 1:1 实现,是通过使用内核线程的一种高级接口————轻量级进程来实现的。

    优势:每个轻量级进程都成为一个独立的调度单元,即使其中某一个轻量级进程在系统调度中被阻塞了,也不会影响整个进程继续工作。

    劣势:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、同步和析构,都需要进行系统调用,而系统调用的代价相对较高,需要在用户态和内核态中来回切换。其次,每个轻量级进程都需要有一个内核线程来支持,因此轻量级进程需要消耗一定的内核资源,因此一个系统支持的轻量级进程的数量是有限的。

  2. 用户线程的实现

    使用用户线程实现的方式被称为 1:N 实现广义上,只要不是内核线程,都可以认为是用户线程,因此从这种定义来看,轻量级进程也属于用户线程。而狭义上的用户线程是指完全建立在用户空间的线程库上。

    优势:不需要系统内核支援。
    劣势:由于没有系统内核支援,所有的线程操作都需要由用户程序自己去处理,实现起来很复杂。

  3. 混合实现

    一种将内核线程与用户线程一起使用的实现方式,被称为 N:M 实现。

  4. Java 线程的实现

    在 JDK1.2 以前,Java 线程时基于一种被称为“绿色线程”的用户线程实现的,但从 JDK1.3 开始,“主流” 平台上的 “主流” 商用 Java 虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用 1:1 的线程模型

Java 线程的调度

Java 线程调度是抢占式线程调度。

状态转换

  • 新建(New):创建后尚未启动的线程处于这种状态。
  • 运行(Runable):包括操作系统中的 Running 和 Ready 状态,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。
  • 无限期等待(Waiting):处于这种状态的线程不会被分配执行器处理时间,它们要等待被其他线程显式唤醒。以下方法会让线程陷入无限期等待状态:
    • 没有设置 Timeout 参数的 Object::wait() 方法;
    • 没有设置 Timeout 参数的 Thread::join() 方法;
    • LockSupport::park() 方法。
  • 限期等待(Timed Waiting):处于这种状态的线程也不会被分配执行器处理时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
    • Thread::sleep() 方法;
    • 设置了 Timeout 参数的 Object::wait() 方法;
    • 设置了 Timeout 参数的 Thread::join() 方法;
    • LockSupport::parkNanos() 方法;
    • LockSupport::parkUntil() 方法。
  • 阻塞(Blocked):线程被阻塞了,在程序等待进入同步区域的时候,线程将进入这种状态。“阻塞状态” 与 “等待状态” 的区别是 “阻塞状态” 在等待着获取到一个排它锁,这个事件将在另一个线程放弃这个锁的时候发生;而 “等待状态” 则是在等待一段时间,或者唤醒动作的发生。
  • 结束(Terminated):已终止线程的线程状态,线程已经结束执行。

Java 与协程

最初多数的用户线程是被设计成协同式调度,所以它有了一个别名————协程。

内核线程的局限

在每个请求本身执行时间变得很短,线程数量变得很多的情况下,用户线程切换的开销甚至可能会接近于计算本身的开下,这就会造成严重的浪费。

协程的复苏

为什么内核线程调度切换起来成本很高?

内核线程调度的成本主要来自于用户态和内核态之间的状态转换,而这两种状态转换的开销主要来自于响应中断、保护和恢复执行现场的成本。

Java 的解决方案

线程安全与锁优化

线程安全

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

Java 语言中的线程安全

  1. 不可变

    在 Java 语言里面,不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施。

  2. 绝对线程安全

    绝对的线程安全能够完全满足 Brian Goetz 给出的线程安全的定义,这个定义其实是很严格的,一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步操作”可能需要付出非常高昂,甚至不切实际的代价。在 Java API 中标注自己是线程安全的类,大多数都不是绝对线程安全。

  3. 相对线程安全

    相对线程安全就是通常意义上所讲的线程安全,它需要保证对这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

  4. 线程兼容

    线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端使用正确的同步手段来保证对象在并发环境下可以安全的使用。我们平常说一个类不是线程安全的,通常就是指这种情况。

  5. 线程对立

    线程对立是指不管调用端是否采取了同步措施,都无法在多线程中并发使用代码。由于 Java 语言天生就支持多线程特性,这种代码是很少出现的,而且通常是有害的,应当尽量避免。如:Thread.suspend()、Thread.resume()、System.SetIn()、System.setOut() 和 System.runFinalizersOnExit()。

线程安全的实现方法

  1. 互斥同步

    • synchronized 关键字
    • ReentrantLock 重入锁
      • 等待可中断:ReentrantLock.tryLock(timeout,unit)
      • 公平锁:默认是非公平锁,可以通过构造函数传入布尔值使用公平锁,但性能会急剧下降。
      • 锁绑定多个条件:ReentrantLock.newCondition()。
        在JDK6之前,ReentrantLock 的性能明显优于 synchronized 关键字,但是 JVM 团队在 JDK 6 中对 synchronized 关键字做了一系列优化,使得两个锁的性能基本持平。所以在能使用 synchronized 关键字的场景下,都可以优先使用 synchronized 关键字。
  2. 非阻塞同步

    非阻塞同步主要是通过 CAS (比较并交换,Compare And Swap),在 JDK 5 之后,Java 类库提供了 Unsafe 类来使用 CAS 操作,例如:compareAndSwapInt,compareAndSwapLong 等。Unsafe 类并不直接开放给开发者调用,开发者想使用必须通过反射的手段来访问 Unsafe 类,在 JDK 9 之后,Java 类库提供了 VarHandle 类,里面开放了面向用户的 CAS 操作。

  3. 无同步方案

    • 可冲入代码(Reentrant Code)

      如果一个方法的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

    • 线程本地存储(Thread Local Storage)

锁优化

自旋锁和自适应自旋

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除主要判定依据是逃逸分析,如果分析出来一段代码中的所有数据都不会逃逸到当前线程外,那么针对这段代码的锁可以被消除。

锁粗化

如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,将会把锁同步的范围扩展(粗化)到整个操作序列的外部。

轻量级锁

偏向锁

posted @ 2023-03-05 22:53  酱油飘香  阅读(80)  评论(0编辑  收藏  举报