在JVM 学习笔记(三)- 从虚拟机的启动-类加载开始,我们解析了包括类的加载过程、类加载子系统,简述了双亲委派机制等等过程。当一个类被类加载子系统经过:加载、链接、验证等等阶段后,类的相关信息将会存在
方法区当中。既然我们已经有了类的所有信息,那么我们就可以真正地去创建一个实例。
1. New一个对象时发什么了什么?
首先我们能想到 的必然是类加载,但是今天我们要谈的是,一个对象是如何被方法区中已经加载的类模板给实例化出来的。
首先我们在Main函数中有如下的语句:
public static void main(String[] args){
MyClass myClass = new MyClass();
}
这是我们非常常见的new一个对象以创建对象的方法。
-
当JVM遇到一个
new指令时,首先会去JVM中查找,是否包含该类的所有信息,尝试在常量池中定位到这个类的符号引用(即类的带路径全名),并且检查这个符号代表的类是否已经被加载。如果存在那么自然不用加载,否则会去调用加载器从外部加载类。 -
确认加载后,JVM将为新的对象分配内存。对象所需要的内存在类加载完成后可以完全确定,JVM将
堆区的一块内存划分出来。具体的JVM内存管理方法有两种,这两种也和计算机操作系统中的内存管理方法相似:
空闲列表法,分配时找到一个大小符合的空间为其分配即可。这种方法维护了一个空闲分区表,表的每个元素都是可用的空间。指针碰撞:假设内存是绝对规整的,高地址都是可用的内存,低地址都是不可用的内存,中间留一个指针作为分界点的指示器,当空间被分配出去那么就会让指针向高地址移动。
-
选定分配的内存后,JVM将需要分配到堆内存空间中的数据类型都初始化为零值。
-
虚拟机要对对象头进行必要的设置,例如对象是哪个类的实例,类的元数据信息、对象的HashCode、对象的GC分代年龄等等,都存储于对象的对象头中。
-
调用对象的构造器方法,根据传入的属性值给对象赋值。
-
在
线程的调用栈中创建对象引用,并指向堆中刚刚新建的对象实例。
这个流程走完,一个对象就构建好了。
- 对象创建的过程中, 划分空间时,划分不一定是安全的,多个线程进来要求划分空间,有可能导致指针的多次分配,这就要我们加锁处理,JVM采用的是CAS自旋的方式以保证更新操作的原子性。
- 或者可以采用不同的编号组,内存分配的动作按照线程划分在不同的空间中进行: 为每个线程在Java堆中预先分配一小块内存 ,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)
2. 对象访问定位
回到上面这个语句:
public static void main(String[] args){
MyClass myClass = new MyClass();
}
在JClasslib中的show bytecode我们可以发现Main方法的本地变量表中,就有一个对象的引用数据:指向MyClass的引用类型。
我们知道,我们不仅仅要有实例对象的数据,还需要有实例对象背后的类。
2.1 句柄定位:
我们的Java程序都是通过栈中的引用来访问堆中的对象的,主流的访问方式包括:1. 句柄访问(直接访问)、2. 直接指针访问。
句柄访问:Java 堆中会划分出一块内存来作为句柄池,引用中存储的就是对象句柄的位置,而句柄中包含了对象实例数据和类型数据各自的地址信息。即栈中的引用先指针句柄,句柄再指向具体的对象实例的地址(堆中)、对应类的详细(方法区中)直接指针访问:直接指向堆中的对象实例地址,而实例地址处会另外加上一个信息,用于存放对象类型信息的地址(方法区)。
3. 标记算法
内存被分配出去了,必然伴随着内存的回收,如何判定一个对象、一个变量是否有用,是否有存在的价值,我们该不该回收、如何回收,是一个垃圾收集器必然要考虑的事情。
3.1 如何判断对象是否已死
3.1.1 引用计数法
给对象添加一个引用计数器,每当有一个地方引用它的时候,计数器的数值就+1,当引用失效时,计数器值-1。任何时刻计数器为0的对象就是不可能再被使用的。
但是这种算法对于两个对象相互引用的情况是无能无力的。
class MyClass{
public Object target;
}
class Main{
public static void main(String[] args) {
MyClass myClass = new MyClass();
MyClass myClass2 = new MyClass();
myClass.target = myClass2;
myClass2.target = myClass;
}
}
大概就是这样,二者相互引用,任何时刻都不会出现二者引用计数器为0的情况。
这种有“致命缺陷”的算法,虽然它的效率很高,但是显然在JVM中任何区域都难以使用它。
3.1.2 可达性分析算法
可达性分析算法,既然名称里面有分析,那肯定不能是简单的计数能够解决的。
该算法通过一系列称为GC Roots的对象作为起点,从起点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的时候,证明此对象是不可用的。
这样看来,如何选择GC Roots是比较关键的点,如果选择Object5,那么势必会造成存活的对象被回收。
在Java语言中,可以作为GC Roots的对象包括:
- 虚拟机栈(本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI(Native)方法引用的对象。
3.2 引用类型
3.1 中,我们谈论了如何标记一个对象,引用计数法也好,可达性分析算法也罢,都是对是否存活的对象的一个判断,在JDK1.2以前,Java中的引用的定义很传统:如果Reference类型的数据中,存储的数值代表的是另一块内存的起始地址,那么就称这块内存代表着一个引用。
但是显然,我们不希望所有的对象之间的关系都只是“引用”,例如:
- 我们希望一些对象
永远不要被回收,因为他和我们的程序运行息息相关。 - 我们希望一些对象
只在内存要满的时候再进行回收,因为这些对象的优先级相对较低,但是如果他们能常驻内存,可以加快程序的执行 - 我们还希望一些对象
能够及时地被回收,因为他们只是程序运行的临时变量,用完就“立即”回收,以减少内存占用。
- 这里的立即并不是真正意义上的立即,而是指下一次GC线程工作时,能够把这个变量回收,而不是当场失效,当场回收。我们的GC线程的优先级相对来说较低,所以很难做到真正意义上的“秒回收”。并且回收过程对
Java程序员是不可见的。
为了适应这么多种现实的场景,引用就被划分成了:
强应用:就对应我们的第一种希望:希望GC永远不要回收这2.个强引用的的对象;软引用:而软引用则用来描述一些有用但并非必须的对象:
软引用工作时:如果系统将要发生内存溢出异常之前,会把软引用先列入回收范围内进行回收,如果回收后还是内存不足,才会抛出内存溢出异常。
弱引用:用来描述非必须对象的,无论内存是否充足,只要GC到来,都进行GC回收。虚引用:也称幽灵引用/幻影引用,一个对象是否有虚引 用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。
而在Java中的使用,如下:
//强引用
MyClass myClass = new MyClass();
System.out.println(myClass.target);//正常
//软引用
SoftReference<MyClass> softReference = new SoftReference<>(new MyClass());
softReference.get();
System.out.println(softReference.get().target);//提示可能会造成nullptr
//弱引用
WeakReference<MyClass> weakReference = new WeakReference<>(new MyClass());
weakReference.get();
System.out.println(weakReference.get().target);//提示可能会造成nullptr
//虚引用:仅仅是个示范,这样写是没有意义的,因为print处会直接报出空指针。
PhantomReference<MyClass> phantomReference = new PhantomReference<>(new MyClass(),new ReferenceQueue<>());
phantomReference.get();
System.out.println(phantomReference.get().target);//提示可能会造成nullptr
3.3 finalize方法
这是一个已经被废弃掉的方法,它被定义在Object.java的:
@Deprecated(since="9")
protected void finalize() throws Throwable {}
如果我们要宣告一个对象真的死亡了,那么我们需要经过两次的标记过程:
- 可达性算法分析时,标记没有与GC Roots相连接的引用链,并筛选,是否有必要执行
finalize方法。如果对象没有复写finalize方法或者是finalize方法已经被执行过,虚拟机将认为这两种情况都没必要执行。 - 如果认为对象有必要执行
finalize方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。 这样做的原因是,如果一个对象在finalize方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize方法中成功拯救自己(只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量),那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。
- 值得注意的是:任何一个对象的
finalize方法只会被系统自动执行一次,如果第一次逃脱成功,第二次就不会再有从finalize阶段逃脱的机会了。finalize之所以被废弃的原因是:Finalize本身不是C++中的析构函数,它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。因此,我们也不必要在任何一个Java对象在逻辑上死亡时,手动调用finalize方法。
4. 内存管理相关的前导知识
4.1 内存的分区分配
- 固定分区分配:指系统为应用程序分配的内存是固定大小的块,每个块内进行分配,这种分配的好处是,我们要多少块就分配多少块,不会产生外部碎片。但是会产生块内的碎片。
- 动态分区分配:需要多少就分配多少,那么这样一来就会导致一个问题,如果一个被挤在两个程序之间的很狭小的内存块被回收了,那么会难以再次回收利用。
如图,我们发现固定分区分配会产生内部碎片,在程序1、2、3被完全回收之前,这个内部碎片是无法被回收的,只能被浪费掉。相比之下,动态分区分配似乎就非常科学了,要多少就给多少,程序2紧跟着程序1分配。然而,当我们尝试将程序3回收掉,新加入一个程序4:
由于程序3过于小了,他被移除后,回收的内存空间难以复用,如果整个系统执行的时候非常长,那么就会产生非常多的这种间隙,也会有很多的内存被浪费。
4.2 紧凑技术
针对动态分区分配产生的问题,提出了一种解决方案:紧凑技术。
其实很简单,就是把程序2向左移动一小段,和程序1接壤即可。但是这样移动一小段,意味着我们整个程序2和后续的程序都要进行一次读 + 重写。对性能的消耗是很大的。
5. 垃圾收集算法
我们知道,JVM在Java堆中存储对象,而堆中又近一步划分为新生代、老年代、永久代或者是1.8中的元空间,在方法区中存放类变量、常量池等等数据。
- 在JVM 学习笔记(二)- JVM的结构中,曾经提到过,方法区和永久代的关系。JVM规范只规定了:类加载后的对象必须存放在方法区当中,但是并没有规定方法区应该采用哪种方法来生成。而在永久代/元空间中设置方法区是一种方法区的实现方式。
根据对象回收频率的不同,按不同的规律进行分代,可以有效提高GC的效率。
5.1 在新生代进行回收(复制算法)
前面提到过,新生代的特点是频繁回收,回收的性价比很高。
我们采用一种复制算法进行回收。首先将JVM的新生代空间一分为二,分为区域A和区域B,两个区域轮换着使用。假设区域A是一开始的运行时存放数据的区域:
首先通过上方 的可达性分析算法,我们知道了我们可以以某种方式得知哪些对象是有效的,那些对象是濒死的。我们可以将已经标记有效的对象,从区域A复制到区域B,赋值的同时,自动就解决了外部碎片的问题,区域B就变成运行时的区域,而对区域A进行内存回收。
IBM公司专门研究过,新生代中对象98%都是"朝生夕死",我们不需要1:1的比例来分配内存空间,我们可以将其分成一块较大的Eden空间和两块较小的Survivor空间,回收时,将Eden和Survior1中还存活着的对象一次性地复制到另外一块Survivor2空间上,清理掉Eden和Survior1的空间。HotSpot采用的划分方案是:8:1:1,即Eden区域为8,其余的两块Survior各自为1。
也就是说,我们新生代的对象,生活在Eden区域,Survivor1区域,但是这其中通常只有2%是有效对象,其余98%是要被回收掉的。每次执行复制算法时,只把这2%的有效对象复制到另外一块Survivor2中。当然这只是一个实验室下的数据,如果我们在别的场景下,不一定就是说这10%的Survivor2区域能够保证装得下所有新生代中存活的对象。
- 如果
Survivor2的内存不够了,那么会执行一个分配担保,说白了就是Survivor2空间不够了,管老年代借,直接通过分配担保机制,将分配的对象**“借住”**在老年代。- 传统的折半分配会浪费掉一半的空间,而采用这种8-1-1的分配方式,则只浪费掉了10%的空间,HotSpot虚拟机最初就是这种布局,和IBM的研究无关。
5.2 在老年代中进行回收(标记-清除算法 和标记-整理算法)
如果一个对象,他在新生代的两个Survivor之间复制来复制去,还没有被回收,那自然而然就说明它命不该绝,如果我们再这样折腾下去,效率也会变低,所以我们就应该把它移动到老年代了。
- 以下介绍的两种算法中的
标记,就是上文中,可达性算法中的标记。各自的"清楚"、“整理”才是回收的步骤。
5.2.1 标记-清除算法
最基础的算法就是标记、清除算法,标记完了以后,我们就可以回收了,回收也很简单。
但是我们需要知道这个算法的问题:
- 两个环节(标记、清楚)
效率低。 外部碎片:直接回收会产生大量的不连续的碎片,碎片空间太多会导致后续的大对象的内存分配无法进行。
5.2.2 标记-整理算法
我们将标记-清除算法的第二部变换成了整理。其实就是上文提到的内存紧凑技术,我们在标记完后,仍然存活的对象向前移动,使之紧凑,然后将边界以外的内存回收即可。
5.3 在方法区中进行回收
永久代其实不像它名字所说的一样,里面的对象就一定不会被回收。实际上,在HotSpot中的永久代确实是没有垃圾收集的,并且,在JVM规范中,也确实说过,不要求在方法区实现垃圾收集。
这主要是因为,在方法区进行垃圾收集的性价比比较低。在新生代中,我们一次GC可以回收七到九成的空间,而永久代回收的效率远低于此。
但是永久代也会产生垃圾,主要是一些废弃的常量和无用的类:
-
废弃常量:废弃的常量包括我们常见的String类型的存在常量池中的变量,也包括一些引用的符号常量。例如字符串常量abc存储在常量池中,如果我们没有任何一个引用指向这个abc常量,此时就应该被回收。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。 -
无用的类:主要是指那些已经没有任何用处的类,认定类是否已经完全无用,需要满足:- 该类的所有实例都已经被回收。
- 加载该类的ClassLoader已经被回收。
- 该类对应的
java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
5.4 分代收集技术
实际上我们上面介绍的三种回收算法:复制、标记-整理、标记-清楚算法都是对Java堆空间的回收算法,各自有各自的特点,我们只需要在适合的场景使用即可。例如在新生代采用复制算法,在老年代采用标记-清楚、标记-整理算法。
前者需要被GC的对象多,复制只需要复制少部分的存活对象。而后者则没有额外的空间为其提供分配担保,必须按照标记算法来进行回收。
6. GC停顿
GC线程需要捕捉到当前整个系统的一个能够确保一致性的快照,根据快照对可达性进行分析,当分析快照的时候,肯定不能让对象之间的引用关系不断地变化,这样必然无法正确分析。
这样一来,GC时,必然要停止掉所有的Java执行线程,即常说的STOP THE WORLD。即使是在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
一般来说,在普通服务端的Java程序上来说容忍度相对高,但是在早期Android上这种需要0.016s就输出一帧的系统上,如果产生了GC停顿,动辄几十数百毫秒的暂停,对用户体验的影响那么是非常大的。后来更换了ART虚拟机后,才有了相当的提升。
- CMS收集器:一款具有划时代意义的收集器。前面我们提到的几款收集器在工作期间全程都需要STW,而CMS第一次实现了垃圾收集的并发处理。因此,这款收集器可以有效的减少垃圾收集过程中的停顿时间。CMS收集器是基于标记-清除算法实现的。已经在JDK15中被移除。详细的实现过程可见:参考来源3
参考来源
- 《深入理解Java虚拟机-JVM高级特性与最佳实践》 - 周志明著
- Android GC 那点事
- Java GC机制竟然被Android同学讲的如此透彻。后端同学看了直呼内行