声明:原创作品,转载请注明出处https://www.jianshu.com/p/feb01f5e94e5
最近在看周志明的《深入理解Java虚拟机》,所以打算写几篇关于Java虚拟机的文章,内容包括Java虚拟机的内存管理、垃圾回收、高并发及类加载几部分,主要是对《深入理解Java虚拟机》一书的总结以及自己的一些理解。如果之前没看过《深入理解Java虚拟机》的同学,可以先看看,写的还是非常不错的。今天这篇主要讲下Java虚拟机的内存管理和垃圾回收策略。
内存划分
内存管理中的内存指的就是Java虚拟机运行时存储数据的地方,它被划分成了多个区域,每个区域都有各自的用途,来看下Java虚拟机内存是怎么划分的,
从上图可以看到,内存被划分了五个区域:方法区、堆、虚拟机栈、本地方法栈以及程序计数器。其中方法区和堆是线程共享的,即各个线程都可以访问,而虚拟机栈和本地方法栈以及程序计数器是线程隔离的,什么是线程隔离呢?拿程序计数器举例,每个线程都会有一个各自独立的程序计数器。换句话说就是,一个虚拟机中不管有几个线程都只有一个方法区和一个堆,但是会有多个虚拟机栈、本地方法栈和程序计数器。
接下来挨个看下各个区的作用:
程序计数器
我们知道Java虚拟机在执行程序的时候,其实就是在一条条的执行指令,这个程序计数器你可以理解就是存当前指令的地址,那为什么要存这个地址呢,因为虚拟机或者说CPU在执行多线程或者多任务的时候,不是同时进行的(当然多核处理器除外),而是其中一个线程执行一段时间,再停下来转去执行另一个线程,然后这个线程执行一段时间再转入原来那个线程,这样有个好处就是CPU的资源可以较平均的分配,那么当CPU执行一个线程一段时间后暂停,然后又重新执行时,这时CPU就需要知道这个线程得从哪里接着执行,不可能让这个线程再从头执行一遍,那么程序计数器就起到了一个很好的作用,用来标志某个线程的执行进度,也因此这个程序计数器是一个线程私有的存储区域,因为每个线程的执行进度都是不一样的。
Java虚拟机栈
我们常说的堆栈,其中的栈指的就是这里的Java虚拟机栈,为什么叫栈呢,因为每个方法在执行的时候,都会创建一个栈帧,用来保存方法中的局部变量、操作数栈、动态链接什么的,每一个方法从开始执行到结束,就对应这个栈帧的入栈和出栈。它也是线程私有的,生命周期和线程相同。如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,但是扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
本地方法栈
本地方法栈和Java虚拟机栈很像,只不过Java虚拟机栈是服务于执行Java方法,而本地方法栈是服务于执行本地方法比如C/C++之类的方法。
Java堆
这个大家应该都很熟悉,就是用来存放对象和数组的地方,它是被所有线程共享,虚拟机一启动就创建。堆是Java垃圾回收的重点区域,因此我们常常叫它为GC堆。
方法区
方法区和堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名 Non-Heap(非堆),主要是为了和Java堆区分开来。方法区还有一片区域叫做运行时常量池,主要存放编译器生成的各种字面量和符号引用。
直接内存
直接内存不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁使用。在JDK1.4中新加入了NIO类,引入了基于通道与缓冲区的I/O方式,他可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的作用进行操作,这样可以显著提高性能。虽然直接内存不会受到Java堆大小的限制,但是会受到本机总内存及处理器寻址空间的限制,所以还是会存在内存溢出的风险。
对象
好了,了解了Java虚拟机的一个大致内存情况后,我们再来看下Java虚拟机究竟是怎么使用内存的。我们之前说过Java虚拟机内存中占比最大的就是堆内存的使用,而堆是存储对象的主要区域,所有接下来我们来看下对象的一些存储细节,包括对象的创建、对象的分配、对象的内存布局以及对象的访问。
对象的创建
说起对象的创建很多人都会想到一个关键词:new。没错只要new下一个对象就创建了,但是你有没有想过这背后Java虚拟机到底做了什么事。其实不要看它只有一句简单的语句,其实对虚拟机来说做了很多工作。首先虚拟机会在方法区的常量池中检测有无这个类的符号引用,并且检查这个类有没有被加载、解析、初始化过,如果没有则会先执行类加载过程。类加载过程完成后,Java虚拟机就会为这个对象分配内存。内存分配有两种方式,一种为指针碰撞
,另一种为空闲列表
。
指针碰撞
如上所示,指针碰撞这种存储方式是非常规整的,已存储的区域占一边,未存储的占一边,中间分界用一个指针来指引,每次分配一个对象的时候,只用指针偏移这个对象所占存储空间就好了。
空闲列表
这种方式,其内存空间没有这么规整,而是不连续的。
如上图所示,圆形状的代表已存储的空间。然后虚拟机会维护一个队列,里面记录着哪些内存是可用的。然后需要分配内存的从这里找就可以了。
那么为什么会存在这两种方式呢,这主要和后面要将的垃圾回收的机制有关,内存经过不同的回收机制回收后,其分布会表现的不一样。
另外,对象的分配在多线程中还会存在一定的问题,比如线程A正在给对象分配内存,指针还没改过来,此时线程B分配内存时用的指针还是原来的指针。这样就冲突了。要解决这个问题有两种方式,一种是对分配内存空间的动作加同步处理,这里虚拟机会用到CAS指令。另一种方式是每个线程在堆中分配一小块属于这个线程的内存区域,我们称之为本地线程分配缓冲(TLAB),这样每个线程分配内存所用的空间都是独立的。但是当TLAB用完的时候还是会采用同步的方式。
对象分配
对象的分配主要分配在新生代的Eden区上(一般虚拟机会把内存分为新生代和老生代,新生代又分为Eden区和Survivor区,这个后面将垃圾回收时会提到),如果启动了本地线程分配缓冲,将按线程优先分配在TLAB上,少数情况下也可能直接分配在老年代中,具体分配规则还得取决于哪一种垃圾收集器,以及虚拟机和内存的参数设置。当Eden区没有足够的内存空间进行分配时,虚拟机会发起一次Minor GC(即在新生代进行一次垃圾回收)。不过虚拟机在Minor GC之前还会进行空间分配担保,就是会先判断下老年代的内存空间是否大于新生代所有对象的总内存,如果大于则执行Minor GC,如果这个条件不成立,则会判断HandlePromotionFailure设置值是否允许担保失败,如果允许则会继续判断老年代剩余空间是否大于历次晋升到老年代的对象的平均大小,如果大于则进行Minor GC,不过有可能会失败,如果失败则会进行Full GC(即进行老年代的垃圾回收,Full GC一般都会伴有至少一次的Minor GC,Full GC要比Minor慢10倍以上)。如果老年代的内存空间小于新生代所有对象的总内存,或者HandlePromotionFailure为false则直接进行Full GC。每次Minor GC之后,Eden存活下来的对象就会被移到新生代中的Survivor区中,如果Survivor区中放不下,就会进入老年代,当然前提是老年代有足够空间,否则就会触发Full GC。另外也有一些大对象为直接进入老年代比如一些长字符串和数组,我们应尽量避免这些大对象。当对象从Eden区进入Survivor区后,每经历一次Minor GC并且能顺利活下来,那么他的年龄就加一,如果到了15岁,就会进入老年代,默认是15岁你可以通过 -XX:MaxTenuringThreshold
来设置。如果在Survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。
内存分配完成后,虚拟机需要将分配到的内存都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前到TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。赋完零值,对象会初始化对象头,对象头在稍后的对象布局中讲解。
完成上面的工作后,从虚拟机层面来说一个新的对象已经产生了,但是从Java程序的角度来说对象的创建才刚刚开始,因为init方法也就是我们说的构造方法还没执行。所以一般来说执行new指令后会接着执行init方法,这样一个真正可用的对象才算完全创建成功。
对象的布局
通过上面的分析,我们了解了一个对象的创建过程。接下来我们来看下一个对象里面到底存了哪些数据。有的童鞋可能有疑惑,对象还能存什么数据,不就是我们声明的类中的一些实例数据吗。当然这是其中一部分也是最主要的一部分,除此之外还有对象头和对齐填充。我们分别来看下:
对象头
对象头主要存两部分数据,一部分为对象自身的运行时数据,如HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳,当然这些东西是什么现在暂时不用管,之后的几篇文章中再细讲。这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为Mark Word
。其实需要存储的运行数据很多,已经超出了规定的长度,但是这部分数据有时和对象自身定义的数据无关,如果专门为这部分再开辟空间的话,虚拟机的空间效率就比较低。因此MarkWord 被设计成不是固定的数据结构,会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么MarkWord的32位空间中的25位用于存储对象的HashCode,4位用于存储对象的分代年龄,2位用于存储锁标志位,1位固定为0,而其他状态下对象的存储内容如下
存储内容 | 标志位 | 状态 |
---|---|---|
对象HashCode、对象分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 膨胀 |
空,不需要记录信息 | 11 | GC标记 |
偏向线程,偏向时间戳、对象分代年龄 | 01 | 可偏向 |
类型指针
另一部分主要是存储指向类元数据的指针,就是这个对象是哪个类的实例。当然对象中不一定都要存这个类元指针。另外如果这个对象是一个Java数组的话,那么对象头中还必须保存这个数组的长度,因为普通对象的大小可以直接由类元数据确定,但是数组只能通过实例才知道。
对齐填充
说完对象头,我们再来看下对齐填充。什么是对齐填充呢?由于HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是Java对象的大小必须为8字节的整数倍,而刚才讲的对象头正好是8字节的整数倍,只有对象的实例数据有可能不是8字节的整数倍,因此如果实例数据大小不是8字节的整数倍,虚拟机就会把剩下的填满,这部分就是对齐填充,没有具体的含义,仅仅起到一个占位的作用。
对象的访问
建立对象是为了使用对象,换句话说我们得能访问到我们刚创建的对象。对象的访问目前有两种主流方式,一种为通过句柄访问,一种为直接指针访问。
句柄访问
上图展示了句柄访问的方式,从图中可以看到,Java堆中划出了一部分区域用作句柄池,这个句柄保存了对象的两个地址,对象实际的实例数据存储地址和这个对象的类元数据地址,而我们变量指向的就是这个对象的句柄地址。
指针访问
上图展示的是直接指针访问,可以看到对象的实例数据中保存了类数据的指针,而本地变量直接指向这个对象地址。
那么这两种方式各有什么优势呢?使用句柄访问最大的好处就是本地变量中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中示例数据的指针,而变量中存储的指向句柄的地址不会改变。而使用直接指针最大的好处就是速度更快,因为它只用一次就指向了对象,而句柄访问需要指针指向两次才能访问到示例数据。
垃圾回收
上面我们知道了内存分配以及对象创建以及访问的一些细节,接下来我们来看下Java虚拟机是如何进行垃圾回收的。这里的垃圾指的就是那些已经死去的对象,那么什么叫死去的对象?或者说Java虚拟机是如何判断一个对象是死的?这里介绍两种方法,一种是引用计数算法,另一种是可达性分析算法,我们挨个来看下:
引用计数算法
引用计数算法就是在对象中添加一个引用计数器,每当有一个地方引用了这个对象,这个计数器的值加1,当这个引用失效时就减1,如果这个计数器的值为0,则表示这个对象就已经死了,不可能再被使用。当Java虚拟机进行垃圾回收时遇到这个对象就会把它回收了。不过这个算法有一个问题:如果有两个对象A、B相互引用(如下图所示),但是这两个对象都没有被其他第三者引用,也就是说这两个对象其实已经不可能再被使用了,但是由于他们相互引用,计数器的值都不为0,导致无法被Java虚拟机回收。因此主流的Java虚拟机都已经不再采用这个算法,而是用另一个算法--可达性分析算法
可达性分析算法
这个算法是通过定义一系列的
GC Root
的对象作为起点,从这些起点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Root没有任何引用链相连时,则证明此对象是不可用的。如上图所示,GC Root引用了 Object1,Object1引用了Object2,说明Object2到GC Root是走的通的,也就是说Object2是可用的不会被回收,同理Object3,Object4也是可用的。然而Object5和Object6、Object7有引用关系,但是他们到GC Root是不通的,所有即使他们有引用关系但是还是不可用的,这时Java虚拟机可以对他们进行回收。那么这里的GC Root是什么呢,在Java中有四种对象可作为GC Root:
- 虚拟机栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
引用
上面讲的两种算法其实都和引用有关,在jdk1.2之前,一个对象只有两种状态,要么有引用要么没用引用。Java虚拟机只会对没有引用的对象进行回收,但是这样有一个问题,就是假如有一个场景中存在一个对象,当内存充足的时候不会回收这个对象,但是当内存不足时就会回收这个对象。那么上面的这个只用两种状态的引用就无法满足这个场景了。于是在jdk1.2之后Java对引用的概念进行了扩充。将引用分为强引用(String Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference),这四种引用强度依次逐渐减弱。分别看下这四种引用:
- 强引用:强引用在程序代码中普遍存在,类似“Object obj = new Object()”这个类的引用,只要强引用还存在,Java虚拟机就永远不会回收这个对象。
- 软引用:用来描述一些还有用到时不是必须的对象。在系统将要发生内存溢出异常之前,Java虚拟机会回收这些对象,如果回收后还是内存不足才会发生内存溢出异常。
- 弱引用:也是用来描述一些非必要的对象,但是强度比软引用更弱一点,被弱引用关联的对象只能生存到下一次垃圾收集发生之前,当垃圾收集器工作时,无论当前内存是否足够都会回收掉这个对象
- 虚引用:虚引用也叫幽灵引用,它是最弱的一种引用关系,他不会影响Java虚拟机回收机制,它的主要作用就是当被虚引用关联得对象被回收时会向系统发送一个通知。
最后的挣扎
我们上面说过,当一个对象没有引用时,这个对象将会被回收,其实这种说法是不严谨的,因为对象在被回收前还是可以最后挣扎一下。什么意思呢?其实对象中有一个叫做finalize的方法,你可以在这个方法中让这个即将被回收的对象重新连接到引用链上,让它到GC Root的链路是通的,这样这个对象就被救活了。但是这个方法是不建议使用的,因为这个方法只会被虚拟机执行一次,如果第一次执行后这个对象没有救活,那么虚拟机下次就不会再执行而是直接回收了。另外虚拟机在执行这个方法时并不会保证这个方法一定会执行到结束,可能执行到一半就结束了,这样做的原因是为了防止你在这个方法中加入一些比如死循环、耗时这样的骚操作,这些操作可能会直接把虚拟机的整个垃圾回收系统给搞崩了,所以finalize这个方法还是少用,最好不用为妙。
垃圾回收算法
上面分析了哪些对象会被回收,接下来我们来看下,找到这些对象后虚拟机是如何回收的。虚拟机回收有不同的算法实现,主要有标记清除算法
、复制算法
、标记整理算法
和分代算法
。接来下分别来介绍下这几种算法:
标记清除算法
标记清除顾名思义,这个算法分两个步骤:首先需要标记哪些对象是需要被回收的,这个就是我们刚才上面介绍的,标记出来后就可以对他们进行清除。这也是最基础的回收算法,因为后续的回收算法其实多多少少都是经过这个算法修改得到的。不过这个算法有两个缺点:第一,标记和清除是比较耗时间的,效率不是很高。第二就是通过这个算法回收之后我们存储区域中会出现一些不连续的存储区域,如果你要存一个大的对象时,可能就找不到一块连续的存储区域,从而触发另一次垃圾回收操作。
复制算法
复制算法是将现有存储空间划分成两半,每次只用其中的一半,如果这半的空间用完了,就将还存活的对象都复制到另一半上去,然后把之前那一半的空间都清理掉,这样的话就解决了空间连续性的问题,不过也带来了新的问题,就是每次只能使用一半的内存,空间利用率不高。这种算法一般用在回收新生代的内存,在新生代的对象一般生命周期比较短,每次大概有百分之九十的对象会被回收,那么这个存储区域其实没有必要设计成1:1的比例,可以是9:1,这样空间利用率就大大提高了。
标记整理算法
标记整理算法有点类似前面的标记清除算法,不同的是这个标记之后不是直接清除对象,而是把还存活的对象都移动到一边,然后把另一边的都直接清除掉。
分代收集算法
其实这个算法是上面算法的组合,当前主流的商业虚拟机会根据对象的存活周期对内存进行分代,一般分为新生代和老生代,新生代中的对象存活周期比较短,适合复制算法,而老生代中的对象存活周期长,可以用标记清除算法和标记整理算法。
HotSpot的算法实现
上面我们介绍了对象存活判定和垃圾回收算法,但是在具体的商业虚拟机中具体实现算法时为了提高效率肯定需要做一些优化。
枚举根节点
我们上面讲到,判断一个对象是否存活,只要判断一个对象是否在引用链上,但是内存中会存在大量的GCRoot,如果一条条遍历每个引用链,那么将是非常耗时的。而且虚拟机在进行可达性分析时,会暂停Java所有线程,因为如果你在分析对象的引用关系时,如果这边线程还在继续进行那么这个引用链的关系势必会打乱,虚拟机的回收工作也将无法进行,就好比你妈在家打扫卫生,而你却在旁边不停的扔垃圾,你妈可能会崩溃的。由于暂停了所有线程,在可达性分析的时候就会卡顿下,如果分析的时间越久那么卡顿的时间也将会越久。因此为了提高虚拟机可达性分析效率,引入一个OopMap的数据结构,你可以理解这个数据结构是用来存放对象的引用,每当需要可达性分析的时候,不用挨个遍历引用链,只用扫描这个数据结构就可以了,效率将大大提升。
安全点
但是随之而来一个问题,引起OopMap变化的指令会多,如果为每一条指令都生成OopMap的话,那么会需要额外的内存空间,导致GC的空间成本变高。为解决这个问题,OopMap只会在某些特定位置发生更新,这个位置就是安全点。这个其实有点类似有些单机闯关游戏中的记录保存功能,在游戏的某些位置比如通过一个小关卡,这时游戏会自动保存记录,而不是每时每刻的保存。同样的在程序中这个安全点的设置也是有一定规律的,比如会在方法调用、循环跳转、异常跳转等位置会设置安全点。设置安全点,意味着虚拟机的垃圾回收也只能在安全点进行,如果不在安全点,这时的OopMap的引用关系就会有问题。但是当虚拟机进行垃圾回收时,有的程序或者说线程其实不在安全点,那么这时就需要想办法让线程跑到安全点上去。虚拟机提供了两种方法让线程跑到安全点上去:抢先试中断和主动式中断。抢先试中断是当虚拟机发生垃圾回收会停掉所有线程,如果某个线程不在安全点,则恢复再重新跑到安全点上,不过现在几乎没有虚拟机是采用这种方式。第二种主动式中断是虚拟机在垃圾回收时在安全点设置标记,当线程执行到标记位置时就主动停止线程。
安全区域
其实上面还有一个问题,就是当虚拟机发生垃圾回收时,某个线程正好处于Sleep状态,无法响应中断进入安全点。这时就需要引入一个安全区域的概念。安全区域是指在一段代码片段中,引用关系不会发生变化,在这个区域的任意地方开始垃圾回收都是安全的。当线程执行到安全区域时,会标记自己已经进入安全区域,这时如果虚拟机要垃圾回收就不用管那些标记自己进入安全区域的线程。当线程要离开安全区域时,它就要检查系统是否已经完成了根节点枚举。如果完成了那么线程就继续执行,否则就必须等到可以离开安全区域的信号为止。
其实安全点和安全区域有点像你在家里,你妈在拖地的情景。当你妈在客厅拖地的时候,你肯定不能乱走动,因为你边走你妈边拖地,估计你妈要疯了。于是当你妈要拖地的时候,会在客厅里划出几个位置,比如凳子、沙发等,每次拖地你就在那不要动。这个位置就类似于安全点。那么有时你妈拖地时你正好不在凳子上,那么你妈就会把你赶过去,这个叫抢先式中断。当然你妈也可以在凳子、沙发上插个小红旗,当你走动遇到小红旗时,你就呆那不要动,这个就叫主动式中断。当然有的时候你妈拖地时,你躺地上睡着了。。你又是个200斤的大胖子,你妈不管怎么搞,都不能把你弄到凳子上。于是想出了一个办法,就是当你进到你自己的房间时就让你告诉她一声,这时你妈就可以放飞自我打扫客厅了,但是如果你要出来的时候就得看看你妈地拖好了没有,如果没有你就乖乖的等着吧,直到你妈说可以出来你才能出来。那么这里你的房间就好比安全区域。