Java内存模型与垃圾回收机制

前段时间Java 17作为LTS版本发布,距离Java 8已经跨越多个版本,总结新特性,除去一些语法糖外,在并发和性能优化上有很多亮点,本文梳理Java内存模型和总结垃圾回收器特点。 Java内存模型最早在Java 5版本提出,规范可以参考JSR 133JSL( Java Language Specification)。原始的参考资料可以在http://www.cs.umd.edu/users/pugh/java/memoryModel/ 找到

内存模型描述了与处理器交互的高速内存缓存的结构和管理方式,定义了多线程对内存的合法操作方式,同时使用底层硬件和编译器优化来正确执行。相对C++在设计时候并没有考虑支持多线程,JMM要复杂的多。更多JVM底层设计可以多看看Doug Lea大牛的文章

内存模型

重排序与happen-before规则

现代编译器和处理器并不会一定按照代码中的顺序执行程序指令,会进行重排序。重排序也可分为编译器优化的重排序和处理器重排序(指令并行的重排序和内存系统重排序),这些重排序会导致多线程程序执行出现内存可见性的问题(不满足顺序一致性,执行的结果与顺序执行的结果保持一致)。JMM通过禁止特定类型的重排序,来提供一致的内存可见性保障,例如通过插入特定类型的内存栅栏(memeory barriers)指令来禁止处理器重排序,在生成字节码的时候,根据处理器的不同内存模型产生不同的内存栅栏。 JMM在设计的时候,既要考虑内存模型易于理解和编程,同时能够对编译器和处理器的的约束少,这样底层可以做更多优化的事情,所以在设计的时候需要寻找比较好的平衡点。JSR133引入了Happen-before原则,即如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必存在happen-before关系,这两个操作可以在单线程也可以在不同的线程中。通过happen-before来阐述操作之间的内存可见性,对于编程来说简单易懂,避免去了解复杂的重排序规则和实现。

内存结构

JVM对内存数据区域定义了以下几种类型:

  • 程序计数器(PC,Program Counter)

用于存储指向下一条指令的地址,在多线程执行的时候,处理器会频繁切换执行任务导致中断和恢复,这样会对每个线程都分配一个PC,用于存储决定当前线程下一步执行的指令。因为存储的是地址,PC占用的内存空间极小,但访问速度要更快。程序执行过程中控制流,例如条件分支,循环,异常等都需要依赖PC来完成。JVM规范中PC不会出现OOM异常。

  • 栈(Stack)

创建线程后,分配方法调用栈,用于存储局部变量,方法的调用和返回,部分结果。栈的大小可以固定也可以动态执行,对应会出现超过栈容量的StackOverflowError和动态申请更多内存时候出现OutOfMemoryError。通过参数-Xss可以指定最大的栈空间大小。

  • 本地方法栈(Native Stack)

JVM调用本地方法的调用栈。因为JVM规范并没有明确使用的语言,实现方式,数据结构等,所以也可以不需要独立实现本地方法栈,例如HotSpot JVM中本地方法栈和栈就合并为一个。

  • 堆(Heap)

用于存储对象实例,几乎所有的对象实例和数据都放在堆中,并且线程间共享。Java堆在物理上可以是不连续的空间。可以是固定大小的,也可以是可扩展的,通过参数-Xmx,-Xms来指定最大最小指。为了高效管理堆内存,引入分代(年轻代,老年代)概念,如果没有足够的堆内存进行分配,且无法再扩展时候,会抛出OutOfMemory异常。可以通过-Xmx和-Xms来指定堆的起始大小和最大值,Java 7 之后可以通过设置-XX:+UseAdaptiveSizePolicy开启动态调整JVM堆各区域的大小和进入老年代的年龄。另外,随着JIT编译器的发展和逃逸分析技术的成熟,栈上分配,标量替换优化等会带来一些改变,所有对象都分配到堆上变成不那么“绝对”。

新生代 :新对象创建时的地方,具体分为三部分:eden和survivor(from和to),默认比例事8:1:1。新创建对象填充到eden区,当空间不足的时候执行Minor GC,eden中存活的对象会转移到surivor to区中,同时survior from区对象也会经历一次Minor GC,根据年龄情况转移到surivor to区和老年代中,这时候from和to的区域会互换角色,Minor GC采用的是复制算法,这样总有一个survivor(to)是空的。minor GC会重复此过程,直到to区被填满后,整体复制到老年代中

老年代 :经过多轮Minor GC依然村多的对象会移动到老年代,大对象直接进入老年代。垃圾回收为Major GC,通常比Minor GC慢很多

TLAB:Thread Local Allocation Buffer,在eden区为每一个线程分配专有的缓冲区,TLAB可以避免在分配内存时候的一些线程安全问题,提升内存分配的吞吐量,OpenJDK相关的版本大都采用了此快速分配策略。因为堆在线程中是共享的,而JVM中创建线程又是频繁的,因此在堆中划分内存空间线程不安全,需要加锁从而影响性能。可以通过-XX:UseTLAB参数开启TLAB,默认情况下只占1% eden空间。

  • 方法区(Method Area)

用于存储虚拟机加载的类信息,常量,静态变量等数据,在线程间共享,对于存在永生代(Permanet)概念的虚拟机(hotspot),方法区存在于永生代,在Java8 之后,引入元空间(Metaspace)取代永生代,永生代和元空间都可以认为是方法区的不同实现。几点区别:

  1. 永生代是堆内存的一部分,和新生代,老生代物理内存上是连续的,受GC管理。元空间存在于本地内存(堆外内存)不受GC控制,较少会出现OOM
  2. Java 8 通过设置-XX:MetaspaceSize和-XX:MaxMetaspaceSize参数控制元空间,而之前是通过PermSize来设置
  3. 元空间存储类的信息,而静态变量,常量池等并入堆中,永生代的数据分割到了元空间和堆中
  4. JVM规范中方法区在逻辑上是堆的一部分,但实际上与堆事分开的(no-heap)

方法区可以不进行GC,不过在此区域的数据也会有涉及到常量池,Java类卸载等内存操作,并非一直不变,当无法满足内存分配的需求时候,会有OutOfMemoryError出现。

垃圾回收器GC

内存回收算法分标记-清除,标记-整理,复制三类,虚拟机内存分成几块(分代)采用不同的算法:

  • 新生代:复制算法
  • 老年代:标记-清除,标记-整理 针对Hotspot虚拟机,垃圾回收器可以按照回收区域和并行化进行分类。

按照回收区域:

  • 部分收集:不是整个收集堆的
    1. Minor GC/Young GC:新生代收集器
    2. Major GC/Old GC:老年代收集器:只有 CMS 单独收集老年代,很多时候Major GC和Full GC混合使用,需要区分老年代回收还是完全回收
    3. Mixed GC:整个新生代和部分老年代收集器,目前只有G1
  • 整堆收集(Full GC):收集整个堆和方法区

按并行化:串行指垃圾回收与用户程序交替执行,垃圾回收的时候,用户程序停顿;并行指垃圾回收和用户程序并行。除了CMS,G1,Z外,其他都是串行

按照多线程:单线程是指只使用一个线程进行回收,多线程是使用多个

  1. Serial GC:单线程,串行。简单高效,常用于桌面环境,不需要线程间的交互开销
  2. ParNew GC:多线程,串行。即Serial的多线程版本,默认开启的数量与CPU相同
  3. Parallel Scavenge GC:多线程,串行。吞吐量优先,即牺牲用户线程的响应时间来提升CPU计算时间,常用后台计算而用户交互少的场景
  4. Serial Old GC:Serial的老年代版本,作为CMS发生 Concurrent Mode Failure 时后备使用
  5. Parallel Old GC:Scavenge的Serial老年代版本
  6. CMS GC:多线程,并行。采用标记-清除算法,容易导致垃圾碎片,容易造成无法找到足够大的空间分配,导致Full GC
  7. G1 GC:多线程,并行。面向服务器端,取代CMS为目的。
  8. Z GC:多线程,并行。适合于大内存低延迟服务场景。

其他语言内存模型

JVM的一些基本原则也适用于其他现代多线程编程语言,C/C++,Javascript,Rust,Go,Swift等。保持无竞争数据程序的顺序一致性,通过同步原子操作建立happen-before关系解决有竞争数据读写结果。

受到JVM的启发,在C++ 11中定义了类似的内存模型,通过引入Memory Order机制,在标准库层面支持多线程并发的控制,而屏蔽掉了不同处理器和编译器的重排序优化。Rust和Swift都采用了C/C++内存模型。Javascript和Go语言的内存模型,更多参考了Java的设计,详细的可以看一下Russ Cox的相关文章

参考文章