Java垃圾回收详解

·  阅读 1246
Java垃圾回收详解

1.GC介绍

什么是垃圾回收(GC)?

垃圾回收,顾名思义,便是将已经分配出去的,但却不再使用的内存回收回来,以便能够再次分配。


在 Java 虚拟机的语境下,垃圾指的是死亡的对象所占据的堆空间。


Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象。它从一系列 GC Roots 出发,边标记边探索所有被引用的对象。


为了防止在标记过程中堆栈的状态发生改变,Java 虚拟机采取安全点机制来实现 Stop-the-world 操作,暂停其他非垃圾回收线程。


回收死亡对象的内存共有三种方式,分别为:会造成内存碎片的清除、性能开销较大的压缩、以及堆使用效率较低的复制


Java 虚拟机将堆分为新生代和老年代,并且对不同代采用不同的垃圾回收算法。

其中,新生代分为 Eden 区和两个大小一致的 Survivor 区,并且其中一个 Survivor 区是空的。在只针对新生代的 Minor GC 中,Eden 区和非空 Survivor 区的存活对象会被复制到空的 Survivor 区中,当 Survivor 区中的存活对象复制次数超过一定数值时,它将被晋升至老年代。

因为 Minor GC 只针对新生代进行垃圾回收,所以在枚举 GC Roots 的时候,它需要考虑从老年代到新生代的引用。为了避免扫描整个老年代,Java 虚拟机引入了名为卡表的技术,大致地标出可能存在老年代到新生代引用的内存区域。


下面详细说一下。


判断对象死亡的方法-引用计数法

要回收垃圾,就要先判断什么是垃圾,也就是哪些对象是已经死亡的。

那么如何辨别一个对象是存还是亡呢?


我们先来讲一种古老的辨别方法:**引用计数法(reference counting)**


它的做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为 0,则说明该对象已经死亡,便可以被回收了。

引用计数法的具体实现是这样子的:

如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器 +1。如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器 -1。也就是说,我们需要截获所有的引用更新操作,并且相应地增减目标对象的引用计数器。


引用计数法的弊端

除了需要额外的空间来存储计数器,以及繁琐的更新操作,引用计数法还有一个重大的漏洞,那便是无法处理循环引用对象。


引用计数法的弊端-循环引用造成内存泄露的例子:

假设对象 a 与 b 相互引用,除此之外没有其他引用指向 a 或者 b。在这种情况下,a 和 b 实际上已经死了,但由于它们的引用计数器皆不为 0,在引用计数法的心中,这两个对象还活着。因此,这些循环引用对象所占据的空间将不可回收,从而造成了内存泄露。


判断对象死亡的方法-可达性分析算法

目前 Java 虚拟机的主流垃圾回收器采取的是**可达性分析算法**


可达性分析算法的实质在于将一系列 **GC Roots** 作为初始的**存活对象合集(live set)**,然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。


什么是 GC Roots 呢?

我们可以暂时理解为由堆外指向堆内的引用,

一般而言,GC Roots 包括(但不限于)如下几种:

- Java 方法栈桢中的局部变量;

- 已加载类的静态变量;

- JNI handles

- 已启动且未停止的 Java 线程。


- 可达性分析优点:解决循环引用问题

可达性分析可以解决引用计数法所不能解决的循环引用问题。

举例来说,即便对象 a 和 b 相互引用,只要从 GC Roots 出发无法到达 a 或者 b,那么可达性分析便不会将它们加入存活对象合集之中。


- 可达性分析算法的问题

虽然可达性分析的算法本身很简明,但是在实践中还是有不少其他问题需要解决的。

比如说,在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为 null)或者漏报(将引用设置为未被访问过的对象)。

误报并没有什么伤害,Java 虚拟机至多损失了部分垃圾回收的机会。

漏报则比较麻烦,因为垃圾回收器可能回收事实上仍被引用的对象内存。一旦从原引用访问已经被回收了的对象,则很有可能会直接导致 Java 虚拟机崩溃。


Stop-the-world (STW)以及安全点(safepoint)


怎么解决对象引用漏报的问题呢?

在 Java 虚拟机里,传统的垃圾回收算法采用的是一种简单粗暴的方式,那便是 Stop-the-world,停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)

Java 虚拟机中的 Stop-the-world 是通过安全点(safepoint)机制来实现的。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程进行独占的工作。

就好像游戏或者演习中的安全词。一旦垃圾回收线程喊出了安全词,其他非垃圾回收线程便会一一停下。


安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java 虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。


举个例子,当 Java 程序通过 JNI 执行本地代码时,如果这段代码不访问 Java 对象、调用 Java 方法或者返回至原 Java 方法,那么 Java 虚拟机的堆栈不会发生改变,也就代表着这段本地代码可以作为同一个安全点。

只要不离开这个安全点,Java 虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。

由于本地代码需要通过 JNI 的 API 来完成上述三个操作,因此 Java 虚拟机仅需在 API 的入口处进行安全点检测(safepoint poll),测试是否有其他线程请求停留在安全点里,便可以在必要的时候挂起当前线程。

除了执行 JNI 本地代码外,Java 线程还有其他几种状态:解释执行字节码、执行即时编译器生成的机器码和线程阻塞。


阻塞的线程由于处于 Java 虚拟机线程调度器的掌控之下,因此属于安全点。


其他几种状态则是运行状态,需要虚拟机保证在可预见的时间内进入安全点。否则,垃圾回收线程可能长期处于等待所有线程进入安全点的状态,从而变相地提高了垃圾回收的暂停时间。

对于解释执行来说,字节码与字节码之间皆可作为安全点。Java 虚拟机采取的做法是,当有安全点请求时,执行一条字节码便进行一次安全点检测。执行即时编译器生成的机器码则比较复杂。

由于这些代码直接运行在底层硬件之上,不受 Java 虚拟机掌控,因此在生成机器码时,即时编译器需要插入安全点检测,以避免机器码长时间没有安全点检测的情况。


HotSpot 虚拟机的做法便是在生成代码的方法出口以及非计数循环的循环回边(back-edge)处插入安全点检测。


那么为什么不在每一条机器码或者每一个机器码基本块处插入安全点检测呢?

原因主要有两个。

第一,安全点检测本身也有一定的开销。不过 HotSpot 虚拟机已经将机器码中安全点检测简化为一个内存访问操作。在有安全点请求的情况下,Java 虚拟机会将安全点检测访问的内存所在的页设置为不可读,并且定义一个 segfault 处理器,来截获因访问该不可读内存而触发 segfault 的线程,并将它们挂起。

第二,即时编译器生成的机器码打乱了原本栈桢上的对象分布状况。在进入安全点时,机器码还需提供一些额外的信息,来表明哪些寄存器,或者当前栈帧上的哪些内存空间存放着指向对象的引用,以便垃圾回收器能够枚举 GC Roots。由于这些信息需要不少空间来存储,因此即时编译器会尽量避免过多的安全点检测。


不过,不同的即时编译器插入安全点检测的位置也可能不同。以 Graal 为例,除了上述位置外,它还会在计数循环的循环回边处插入安全点检测。其他的虚拟机也可能选取方法入口而非方法出口来插入安全点检测。不管如何,其目的都是在可接受的性能开销以及内存开销之内,避免机器码长时间不进入安全点的情况,间接地减少垃圾回收的暂停时间。除了垃圾回收之外,Java 虚拟机其他一些对堆栈内容的一致性有要求的操作也会用到安全点这一机制。


2.三种垃圾回收算法

当标记完所有的存活对象时,我们便可以进行死亡对象的回收工作了。

主流的基础回收方式可分为三种。

清除(sweep

第一种是清除(sweep),即把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。

清除这种回收方式的优点是原理极其简单。

但是清除有两个缺点:

一是会造成内存碎片。由于 Java 虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。

二是分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointer bumping)来做分配。而对于空闲列表,Java 虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。

压缩(compact)

第二种是压缩(compact),即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。

这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。

复制(copy)

第三种则是复制(copy),即把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。

复制这种回收方式同样能够解决内存碎片化的问题,但是它的缺点也极其明显,即堆空间的使用效率极其低下。


当然,现代的垃圾回收器往往会综合上述几种回收方式,综合它们优点的同时规避它们的缺点。


3.垃圾分代回收算法

大部分的 Java 对象只存活一小段时间,而存活下来的小部分 Java 对象则会存活很长一段时间。这个假设,或者说被验证普遍存在的现象,造就了 Java 虚拟机的分代回收思想。简单来说,就是将堆空间划分为两代,分别叫做新生代老年代。新生代用来存储新建的对象。当对象存活时间够长时,则将其移动到老年代。

下图是一个统计 Java 对象生命周期的动态分,可以很好的说明这个假设。


Java 虚拟机可以给不同代使用不同的回收算法。


对于新生代,我们猜测大部分的 Java 对象只存活一小段时间,那么便可以频繁地采用耗时较短的垃圾回收算法,让大部分的垃圾都能够在新生代被回收掉。


对于老年代,我们猜测大部分的垃圾已经在新生代中被回收了,而在老年代中的对象有大概率会继续存活。当真正触发针对老年代的回收时,则代表这个假设出错了,或者堆的空间已经耗尽了。这时候,Java 虚拟机往往需要做一次全堆扫描,耗时也将不计成本。(当然,现代的垃圾回收器都在并发收集的道路上发展,来避免这种全堆扫描的情况。)


Java 虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为 Eden 区,以及两个大小相同的 Survivor 区。


默认情况下,Java 虚拟机采取的是一种动态分配的策略(对应 Java 虚拟机参数 -XX:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及 Survivor 区的使用情况动态调整 Eden 区和 Survivor 区的比例。

当然,你也可以通过参数 -XX:SurvivorRatio 来固定这个比例。但是需要注意的是,其中一个 Survivor 区会一直为空,因此比例越低浪费的堆空间将越高。


通常来说,当我们调用 new 指令时,它会在 Eden 区中划出一块作为存储对象的内存。

由于堆空间是线程共享的,因此直接在这里边划空间是需要进行同步的。否则,将有可能出现两个对象共用一段内存的事故。

用“停车位”打的比方的话,这里就相当于两个司机(线程)同时将车停入同一个停车位,因而发生剐蹭事故。

Java 虚拟机的解决方法是为每个司机预先申请多个停车位,并且只允许该司机停在自己的停车位上。

那么当司机的停车位用完了该怎么办呢(假设这个司机代客泊车)?答案是:再申请多个停车位便可以了。这项技术被称之为 TLAB(Thread Local Allocation Buffer,对应虚拟机参数 -XX:+UseTLAB,默认开启)。

具体来说,每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB。这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。接下来的 new 指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。


当 Eden 区的空间耗尽了怎么办?这个时候 Java 虚拟机便会触发一次 Minor GC,来收集新生代的垃圾。存活下来的对象,则会被送到 Survivor 区。

新生代共有两个 Survivor 区,我们分别用 from 和 to 来指代。其中 to 指向的 Survivior 区是空的。

当发生 Minor GC 时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的。

Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。

总而言之,当发生 Minor GC 时,我们应用了标记 - 复制算法,将 Survivor 区中的老存活对象晋升到老年代,然后将剩下的存活对象和 Eden 区的存活对象复制到另一个 Survivor 区中。理想情况下,Eden 区中的对象基本都死亡了,那么需要复制的数据将非常少,因此采用这种标记 - 复制算法的效果极好。

Minor GC 的另外一个好处是不用对整个堆进行垃圾回收。但是,它却有一个问题,那就是老年代的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots。

这样一来,岂不是又做了一次全堆扫描呢?

HotSpot 给出的解决方案是一项叫做**卡表(Card Table)**的技术。


卡表(Card Table)

该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用

如果可能存在,那么我们就认为这张卡是脏的。在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。

当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

由于 Minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。


在 Minor GC 之前,我们并不能确保脏卡中包含指向新生代对象的引用。其原因和如何设置卡的标识位有关。

首先,如果想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么 Java 虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。这个操作在解释执行器中比较容易实现。但是在即时编译器生成的机器码中,则需要插入额外的逻辑。这也就是所谓的写屏障(write barrier,注意不要和 volatile 字段的写屏障混淆)。写屏障需要尽可能地保持简洁。这是因为我们并不希望在每条引用型实例变量的写指令后跟着一大串注入的指令。因此,写屏障并不会判断更新后的引用是否指向新生代中的对象,而是宁可错杀,不可放过,一律当成可能指向新生代对象的引用。

这么一来,写屏障便可精简为下面的伪代码。

这里右移 9 位相当于除以 512,Java 虚拟机便是通过这种方式来从地址映射到卡表中的索引的。

最终,这段代码会被编译成一条移位指令和一条存储指令。

CARD_TABLE [this address >> 9] = DIRTY;

虽然写屏障不可避免地带来一些开销,但是它能够加大 Minor GC 的吞吐率( 应用运行时间 /(应用运行时间 + 垃圾回收时间) )。总的来说还是值得的。


不过,在高并发环境下,写屏障又带来了虚共享(false sharing)问题。虚共享问题,讲的是几个 volatile 字段出现在同一缓存行里造成的虚共享。这里的虚共享则是卡表中不同卡的标识位之间的虚共享问题。

在 HotSpot 中,卡表是通过 byte 数组来实现的。对于一个 64 字节的缓存行来说,如果用它来加载部分卡表,那么它将对应 64 张卡,也就是 32KB 的内存。如果同时有两个 Java 线程,在这 32KB 内存中进行引用更新操作,那么也将造成存储卡表的同一部分的缓存行的写回、无效化或者同步操作,因而间接影响程序性能。为此,HotSpot 引入了一个新的参数 -XX:+UseCondCardMark,来尽量减少写卡表的操作。其伪代码如下所示:

if (CARD_TABLE [this address >> 9] != DIRTY) CARD_TABLE [this address >> 9] = DIRTY;


4. 垃圾收集器介绍

针对新生代的垃圾回收器共有三个:Serial,Parallel Scavenge 和 Parallel New。

这三个采用的都是标记 - 复制算法。

其中,Serial 是一个单线程的,Parallel New 可以看成 Serial 的多线程版本。Parallel Scavenge 和 Parallel New 类似,但更加注重吞吐率。此外,Parallel Scavenge 不能与 CMS 一起使用。

针对老年代的垃圾回收器也有三个:Serial Old 和 Parallel Old,以及 CMS。

Serial Old 和 Parallel Old 都是标记 - 压缩算法。同样,前者是单线程的,而后者可以看成前者的多线程版本。


CMS 采用的是标记 - 清除算法,并且是并发的。除了少数几个操作需要 Stop-the-world 之外,它可以在应用程序运行过程中进行垃圾回收。在并发收集失败的情况下,Java 虚拟机会使用其他两个压缩型垃圾回收器进行一次垃圾回收。由于 G1 的出现,CMS 在 Java 9 中已被废弃[

G1(Garbage First)是一个横跨新生代和老年代的垃圾回收器。实际上,它已经打乱了前面所说的堆结构,直接将堆分成极其多个区域。每个区域都可以充当 Eden 区、Survivor 区或者老年代中的一个。它采用的是标记 - 压缩算法,而且和 CMS 一样都能够在应用程序运行过程中并发地进行垃圾回收。G1 能够针对每个细分的区域来进行垃圾回收。在选择进行垃圾回收的区域时,它会优先回收死亡对象较多的区域。这也是 G1 名字的由来。

Java 11 引入了 ZGC,宣称暂停时间不超过 10ms。



5.GC相关问题简答


1. JVM的stop-the-world机制非常不友好,有哪些解决之道?原理是什么?

采用并行GC可以减少需要STW的时间,它们会在即时编译器生成的代码中加入写屏障或者读屏障。


2. 压测时出现频繁的GC容易理解,但是有时出现毛刺是因为什么呢?

Y轴应该是时间,那毛刺就是长暂停,一般Full GC就会造成长暂停。


3. Full GC有卡顿,对性能很不利,怎么避免呢?

通过调整新生代大小,使对象在其生命周期内都待在新生代中。这样一来,Minor GC时就可以收集完这些短命对象了。


4.不管什么垃圾回收器都会出现stop the word吗?

目前的垃圾回收器多多少少需要stop the world,但都在朝着尽量减少STW时间发展。

完全的并发GC算法是存在的,但是在实现上一般都会在枚举GC roots时进行STW。


5. 压缩算法是不是也用到了复制呢?

确实是需要复制数据,这样起名主要是为了区分复制到同一个区域中(需要复杂的算法保证引用能够正确更新),还是复制到另一个区域中(可以复制完后统一更新引用)。


6. JVM分代收集新生代对象进入老年代,年龄为什么是15而不是其他的?

HotSpot会在对象头中的标记字段里记录年龄,分配到的空间只有4位,最多只能记录到15。


5.引用


强引用(StrongReference)

强引用(StrongReference)是我们在编程过程中使用的最简单的引用,如代码String s=”abc”中变量s就是字符串对象”abc”的一个强引用。


任何被强引用指向的对象都不能被垃圾回收器回收,这些对象都是在程序中需要的。

StringBuffer stringBuffer = new StringBuffer(); 此时的object也是一个强引用。

强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用 对象来解决内存不足的问题。


软引用(SoftReference)

软引用需要通过SoftReference类来实现,当一个对象只具有软引用时,它有可能被垃圾回收机制回收。对于只有软引用的对象而言,当系统内存空间足够时,它不会被系统回收,程序也可使用该对象;当系统内存空间不足时,系统将会回收它。软引用通常用于对内存敏感的程序中。

软引用是用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。

软引用关联的对象不会被GC回收。JVM在分配空间时,若果Heap空间不足,就会进行相应的GC,但是这次GC并不会收集软引用关联的对象,但是在JVM发现就算进行了一次回收后还是不足(Allocation Failure),JVM会尝试第二次GC,回收软引用关联的对象。

像这种如果内存充足,GC时就保留,内存不够,GC再来收集的功能很适合用在缓存的引用场景中。在使用缓存时有一个原则,如果缓存中有就从缓存获取,如果没有就从数据库中获取,缓存的存在是为了加快计算速度,如果因为缓存导致了内存不足进而整个程序崩溃,那就得不偿失了。


弱引用(WeakReference)

弱引用通过WeakReference类实现,弱引用和软引用很像但是弱引用的级别更低。

弱引用也是用来描述非必须对象的,他的强度比软引用更弱一些,被弱引用关联的对象,在垃圾回收时,如果这个对象只被弱引用关联(没有任何强引用关联他),那么这个对象就会被回收

除了WeakHashMap使用了弱引用,ThreadLocal类中也是用了弱引用。


虚引用(PhantomReference)

一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

虚引用和弱引用对关联对象的回收都不会产生影响,如果只有虚引用活着弱引用关联着对象,那么这个对象就会被回收。它们的不同之处在于弱引用的get方法,虚引用的get方法始终返回null,弱引用可以使用ReferenceQueue,虚引用必须配合ReferenceQueue使用。

JDK中直接内存的回收就用到虚引用,由于JVM自动内存管理的范围是堆内存,而直接内存是在堆内存之外(其实是内存映射文件,自行去理解虚拟内存空间的相关概念),所以直接内存的分配和回收都是有Unsafe类去操作,Java在申请一块直接内存之后,会在堆内存分配一个对象保存这个堆外内存的引用,这个对象被垃圾收集器管理,一旦这个对象被回收,相应的用户线程会收到通知并对直接内存进行清理工作。


6.JNI

JNI是Java Native Interface的缩写,通过使用 Java本地接口书写程序,可以确保代码在不同的平台上方便移植。


7.JVM内存模型

Java虚拟机存在自动内存管理机制,所以程序员不需要用代码去控制内存的使用,正是因为如此,程序出现内存异常问题的时候,如果不了解虚拟机是怎样使用内存的,会很难排查问题。

所谓“免费的才是最贵的”,所以,了解虚拟机的内存模型是很重要的。

Java虚拟机在执行Java程序的过程中会把内存划分若干个不同的数据区域,这些区域有各自的用途,以及创建和销毁的时间。

JVM所管理的内存包括以下几个运行时数据区域:


程序计数器

程度计数器是一块较小的内存空间,可以看作是当前线程执行的字节码的行号指示器

字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都依赖这个计数器完成。

多个线程中指令的执行在一个处理器中是串行的,多个线程在切换的时候,为了能恢复到正确的执行位置,每个线程都需要一个独立的程序计数器

如果线程正在执行的是一个Java方法,计数器记录的是正在执行的虚拟机字节码指令的地址,如果正在执行的是Native方法,计数器值则为空。

此内存区域是唯一一个在Java虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

创建Main.java文件:

public class Main {     
  public static void main(String[] args) {         
    int a = 10, b = 10;         
    int c = a + b;     
  } 
} 


编译Main.java为class文件:javac Main.java

执行javap反汇编为汇编码:javap -c Main.class

Compiled from "Main.java"
public class Main {
  public Main();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: bipush        10
       2: istore_1
       3: bipush        10
       5: istore_2
       6: iload_1
       7: iload_2
       8: iadd
       9: istore_3
      10: return
}

可以看到该类反解析出的汇编指令以及指令的偏移地址,程序计数器保存的值就是指令的偏移地址。


Java 虚拟机栈

Java虚拟机栈也是线程私有的,它的生命周期与线程相同。

虚拟机栈描述的是Java方法执行的内存模型:每个方法执行时会生成一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

局部变量表存放了编译期可知的各种基本数据类型和对象引用类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,或指向一个代表对象的句柄。

Java程序运行的时候,可以有多个线程,每个线程调用的方法就是一个栈帧。

我们在使用IDEA调试程序的时候,在调试面板中看到线程(Threads)、栈帧(Frames)和局部变量(Variables)的情况。


在Java虚拟机规范中:如果线程请求的栈深度大于虚拟机所允许深度,抛出 StackOverflowError 异常;如果虚拟机可以动态扩展,如果扩展时无法申请到足够的内存,会抛出 OutOfMemoryError 异常。


本地方法栈

本地方法栈与虚拟机栈作用非常相似,区别在于本地方法栈为虚拟机使用的Native方法服务,抛出的异常也与虚拟机栈一样。

在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以实现它。


Java 堆

Java堆是Java虚拟机所管理的内存中最大的一块,它是被所有线程共享的一块内存区域,在虚拟机启动时创建。堆内存区域的唯一目的就是存放对象实例。

Java虚拟机规范中描述是:所有的对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展与逃逸分析技术的成熟,栈上分配、标量替换技术会导致一些变化。


Java堆是垃圾收集器管理的主要区域,因此也叫“GC堆”(Garbage Collected Heap)。


从内存回收的角度看,现在收集器基本使用分代收集算法,所以Java堆还可以细分为:Eden 区、From Survivor 区、To Survivor 区等。

从内存分配的角度看,线程共享的Java堆可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。

根据Java虚拟机的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的。

在实现时,既可以是固定大小的,也可以是扩展的,不过当前主流虚拟机都是可扩展的(通过 -Xmx 和 -Xms 控制)。

如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,会抛出 OutOfMemoryError 异常。


方法区

方法区也是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

在HotSpot虚拟机上,很多人都把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队把GC分代收集扩展至方法区,或者是使用永久代来实现方法区而已,这样垃圾收集器就可以像管理Java堆一样管理这部分内存。

Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。虽然这个区域的垃圾回收效率不大,尤其是类型的卸载,条件相当苛刻,但这部分区域的回收确实是必要的。当方法区无法满足内存分配需求时,抛出 OutOfMemoryError 异常。


方法区-运行时常量池

运行时常量池是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

Java虚拟机对Class文件每一部分(包括常量池)的格式有严格规定,但对于运行时常量池没有做任何细节的要求。一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。运行时常量池是方法区的一部分,所以当常量池无法申请到内存时也会抛出 OutOfMemoryError 异常。

使用 java -v <class> 指令来反编译Class文件信息。编写下面Java类,并编译成Class文件:

public class Main {
    private int a = 10;
    private String b = "abc";
}

javac Main.java

javap -v Main.class

Classfile /Users/lijiangtao/Desktop/Main.class
  Last modified 2020-7-20; size 280 bytes
  MD5 checksum e48afaca1ac8e92689386e724c29bbb9
  Compiled from "Main.java"
public class Main
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#17         // java/lang/Object."<init>":()V
   #2 = Fieldref           #5.#18         // Main.a:I
   #3 = String             #19            // abc
   #4 = Fieldref           #5.#20         // Main.b:Ljava/lang/String;
   #5 = Class              #21            // Main
   #6 = Class              #22            // java/lang/Object
   #7 = Utf8               a
   #8 = Utf8               I
   #9 = Utf8               b
  #10 = Utf8               Ljava/lang/String;
  #11 = Utf8               <init>
  #12 = Utf8               ()V
  #13 = Utf8               Code
  #14 = Utf8               LineNumberTable
  #15 = Utf8               SourceFile
  #16 = Utf8               Main.java
  #17 = NameAndType        #11:#12        // "<init>":()V
  #18 = NameAndType        #7:#8          // a:I
  #19 = Utf8               abc
  #20 = NameAndType        #9:#10         // b:Ljava/lang/String;
  #21 = Utf8               Main
  #22 = Utf8               java/lang/Object
{
  public Main();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: bipush        10
         7: putfield      #2                  // Field a:I
        10: aload_0
        11: ldc           #3                  // String abc
        13: putfield      #4                  // Field b:Ljava/lang/String;
        16: return
      LineNumberTable:
        line 1: 0
        line 2: 4
        line 3: 10
}
SourceFile: "Main.java"

根据输出内容可以看到该Class文件的版本号、常量池和已编译后的字节码。


直接内存

直接内存不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但这部分内存也被频繁使用。

在JDK 1.4中新加入的NIO(New Input/Output)类,引入了基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以直接使用Native函数库直接分配堆外内存。

本机直接内存不会受Java堆大小的限制,但会受本机总内存大小以及处理器寻址空间的限制。

服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但忽略直接内存,使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现 OutOfMemoryError 异常。


8.HotSpot 虚拟机对象

8.1. 对象的创建


Java是一门面向对象的语言,在程序运行过程中无时无刻都有对象被创建。

虚拟机在类加载检查通过后,会为新生对象分配内存。对象所需内存的大小在类加载完成后便可以确定,为对象分配空间等同于把一块确定大小的内存从Java堆中划分出来。

假设Java堆中内存是绝对规整的,所有用过的内存放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就是把指针向空闲空间挪动一段对象大小相等的中庸,这种方式称为“指针碰撞”(Bump the Pointer)。


如果Java堆中的内存不规整的,已使用的内存和空闲的内存相互交错,虚拟机就会维护一个列表,记录哪些内存是可用的,在分配的时候从列表中找到一块足够大空间划分给对象实例, 并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。


选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用 Serial、ParNew 等带 Compact 过程的收集器时,系统采用的分配算法是指针碰撞,而使用 CMS 这种基于 Mark-Sweep 算法的收集器时,通常采用空闲列表。

在虚拟机中频繁创建对象,即使是修改指针的位置,在并发情况下也不是线程安全的,解决这问题有两种方案:一种是对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程上的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定,虚拟机是否使用TLAB,可以通过 -XX:+/-UseTLAB 参数来设定。


8.2. 对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局分为:对象头、实例数据和对齐填充。

对象头包括两部分信息:第一部分存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位虚拟机(未开启压缩指针)中分别为32bit和64bit,称为“Mark Word”;另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个Java数组,对象头中还有一块用于记录数组长度的数据。

实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型字段内容,无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。

对齐填充并不是必然存在的,也没有特别的含义,仅仅起着占位符的作用,因为HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍。

8.3. 对象的访问定位


对象创建完后,Java程序需要通过栈上的reference数据来操作堆上的具体对象,由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的,主流的访问方式有使用句柄和直接指针两种。

通过句柄访问对象,Java堆中会划分出一块内存来作为句柄池,reference中存储的是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的地址信息。使用直接指针访问对象,reference中存储的直接就是对象地址。

这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针。使用直接指针访问的最大好处就是速度更快,节省了一次指针定位的开销。


9.参考链接

- [软引用、弱引用、虚引用-他们的特点及应用场景](www.jianshu.com/p/825cca41d…)

- [深入拆解Java虚拟机:垃圾回收](time.geekbang.org/column/arti…)


分类:
后端
标签:
分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改