读书笔记——深入理解JVM(JVM自动内存管理)

简介

本系列为《深入理解Java虚拟机—JVM高级特性与最佳实践》一书的阅读笔记。
本书开头介绍了JVM发展的历史,接着介绍了JVM是如何实现自动内存管理的。
本章节主要介绍:

  1. JVM的存储结构;
  2. JVM的运行机制;
  3. GC 的原理。

1. JVM内存结构和内存溢出异常

JVM实质上就在内存中开辟的一块内存空间,这块空间被JVM占用并被划分成若干个分区(逻辑上的),为了高效的管理,这些分区会执行不同的功能,由于JVM有GC机制,所以我们不用太过于纠结内存泄露的问题,然而当出现内存不足等问题时,我们需要分析出现问题的原因和对JVM进行扩容。在这里插入图片描述

1.1 程序计数器PC

和CPU中的PC意义相同,都是指向当前线程下一次要执行的指令的地址。循环、分支和程序调用都依赖PC。 Java是支持多线程的,所以在线程的切换过程中,需要保存当前线程的PC的值,以便在下次该线程得到时间片后可以正确执行指令,因此,PC是线程私有的。 要注意的是:如果调用的是本地方法(native method)那么该PC的值为undefined

1.2虚拟机栈

Java支持多线程,因此必须保存每个线程的执行情况,以便在下次恢复线程时可以继续执行。 在一个线程中,每次调用一个方法的时候,在一个叫做虚拟机栈的数据结构中创建一个栈帧。 主要包括:

  1. 局部变量表:
  2. 操作数栈;
  3. 方法出口;
  4. 动态链接。
    在这里插入图片描述

1.3 本地方法栈

本地方法栈和Java虚拟机栈的作用相似,本质上都是方法调用时的存储结构,本地方法栈存储的是本地native方法。

1.4 Java堆

堆是被所有线程都共享的一个区域,整个堆空间存储的都是java对象。是Java中几乎全部对象的存储空间。 现代的JVM会对堆进行细分处理,目的是为了优化垃圾回收(GC)的效率。

1.5 方法区

方法区也是一个线程共享的区域,负责存储:

  1. 加载的.class文件;
  2. 常量;
  3. 静态变量;
  4. 代码缓存等。

1.6 运行时常量池

上面提到的方法区中,有一个区域被称为运行时常量池,如字面意思,其存储的主要内容就是运行时产生的各种字面量和符号引用。 该常量池是动态的,即可以存储编译时产生的常量和可以存储运行过程中产生的常量。
最常见的就是String类中的intern方法。该方法是一个native方法。调用 intern 方法时,如果常量池中已经该字符串,则返回池中的字符串;否则将此字符串添加到常量池中,并返回字符串的引用。

1.7 直接内存

直接内存是物理机的中频繁被使用的内存,比如NIO中采用的缓冲区就是利用了直接内存,这部分内存的的容量不足错误也会导致JVM抛出OutOfMemoryException

2. HotSopt虚拟机运行原理

2.1 对象如何创建

首先我们需要明确Java编程语言中有哪些可以创建对象的方式:

// 使用new显示声明对象
1. Object o = new Object();

// 利用反射 使用该方式必须保证该类有无参构造方法
Class c = Class.forName("java.lang.String");
String s = c.newInstance();

Constructor constructor = String.getConstructor();
String s = constructor.newInstance();

// 利用clone 注意:SomeObject类必须实现了Cloneable接口
SomeObject newObject = oldObject.clone();

// 利用反序列化创建对象, 使用反序列化的工具即可

2.2 创建对象的过程

  1. 检查类是否被加载到方法区中:JVM首先会去常量池定位到一个类的符号引用(如果有则说明该类已经被加载到了方法区),如果没有则进行类加载过程;
  2. 为新生对象分配堆空间:在完成类加载后,就可以确定一个对象所需的空间的大小。此时需要考虑两个问题:
    1. 如何分配空闲的内存给新对象 (内存规整性) :常用的方式有:指针碰撞和空闲列表。指针碰撞适用于规整分配的内存空间,空闲列表则是要记录目前内存中可用的空间位置,分配一个合适的内存给新对象。内存是否规整取决于GC算法是否带有空间压缩。
    2. 多线程情况下,如何保证空间分配的线程安全性(是否在本地线程分配缓冲区中分配对象):
      • 一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败 重试的方式保证更新操作的原子性;
      • 另外一种是把内存分配的动作按照线程划分在不同的空间之中进 行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完 了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
  3. 初始化内存空间;
  4. 设置对象头:对象头包含了该对象的所有描述信息,存储的元数据。
  5. (可选)调用()方法:调用类中的构造方法,此时,在程序员的眼中对象创建完成。

2.3 对象内存布局

  1. 对象头(Header):存储的是类相关的元数据。
    • 对象的 运行时数据:希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
    • 对象的类型指针:即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。
  2. 实例数据(Instance Data):我们在程序中定义的各种字段,也包括从父类中继承过来的。
  3. 对齐填充(Padding):由于Hotspot虚拟机要求对象的大小都必须是8字节的整数倍,所以需要通过Padding来补齐对应的长度。

2.4 对象定位

在Java编程语言中有一类数据称为引用,他指向了某个对象的内存地址。在JVM的实现上,采用不同的策略实现引用指向对象这个过程:

  1. 采用句柄:通常会在堆中开辟一部分空间,称为句柄池。引用中存储的数据是句柄中的地址,通过在对应句柄中的地址再去寻找真正的内存地址(相当于是一个二级映射)。
  2. 直接指向地址:引用指向的就是真正的内存地址。

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

由于动态分配内存机制和GC内存回收机制,使得Java编程语言在创建对象时非常方便。但是当GC成为系统的瓶颈时,就有必要对GC进行管理和监控。 GC关心的三个问题:

  1. 如何判断对象需要被回收;
  2. 何时回收;
  3. 回收策略如何制定。

3.1 如何判断对象已死

1. 引用技术法

思路很简单:为每个对象设置一个应用个数字段,当一个引用指向他时就将该数字自增,当一个引用取消指向该对象时,就将该字段自减一。 但是如果出现了循环引用的情况,该GC策略就会出错,因此虽然引用计数法思路简单,但是需要很多额外的错误处理。

2. 可达性分析法

字面理解就是:当一个对象不能通过任何对象再到达它时,就认为该对象已死。 如果将所有对象看成是一个节点,有关联的对象之间代表有边,可以访问。从图论的角度来看,就是包含该对象的子图中只有该对象自己(形成孤岛),那么就任务该对象已死。
目前Java中是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。
该算法通过将一系列的对象作为根对象(GC Roots对象),形成一个初始集合,通过遍历这些GC Roots对象形成的树,如果无法遍历到目标对象,则认为该对象已经死亡。
在这里插入图片描述
常见的GC Root对象有:

  • **在虚拟机栈(栈帧中的本地变量表)**中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。 ·在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

3. 引用

对象的回收是和引用相关的,在早期的JDK版本中,引用就是指向的内存地址,但是之后引用的类型也发生了分化(主要是从GC回收的角度来看的):

  • 强引用:是一种强烈的关联定义,GC永远不会回收强引用指向的对象;
  • 软引用:在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收;
  • 弱引用:弱引用指向的对象的生命周期是从对象创建到下一次GC发生前这段时间;
  • 虚引用:无法通过虚引用访问对象,为一个对象设置虚 引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

4. 对象的死亡过程

一个对象死亡需要经过两次标记过程:

  1. 当没有通过GC Roots集合无法找到一条引用链(References chain)来找到当前对象,则标记一次;
  2. 随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法
    • 有必要执行:执行finalize()方法,如果该对象在finalize()中重新将自身对象重新赋值给某个对象的引用,那么他就会在本次GC回收中逃逸出来。但是如果第二次执行到finalize时,就会直接回收。
    • 没必要执行:假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过了,那么直接将该对象回收;

5. 回收方法区

方法区的回收代价比较高,通常是对类型的卸载,需要满足很严格的条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

3.2 回收算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC), 书中主要介绍的就是追踪式垃圾收集(间接垃圾收集)。

1. 分代收集理论

大多数GC都是采用的分代收集方式,其都是基于两个假说:
1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
基于这两个假说,得出一个GC的设计原则:收集器应该将Java堆按照其生命周期的特点划分出不同的区域,然后根据对象的年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区 域之中存储
这样做的好处是:

  1. 针对容易死亡的区域,使用少量的开销标记存活的对象
  2. 针对年龄大的区域,降低GC回收的频率
    我们使用的HotSpot虚拟机,将分成新生代(Young Generation)和老年代(Old Generation)两个区域。在新生代中,每次垃圾收集 时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。 书中定义了一些术语:
    在这里插入图片描述

2. 跨代引用

对象之间会互相引用,两个对象可能存在于不同的代中。 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。 根据跨代引用假说,我们不应该为了检测是否存在跨代引用而且遍历整个老年代,只需要在新生代中开辟一个空间,这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。

3. 标记-清除法

思路就是:将需要回收的对象进行标记,等到足够触发一次GC时,就将标记的对象回收掉,没有使用到上述的各种假说,效率较低。 但是之后的GC策略都是以这个为基础的。
在这里插入图片描述

4. 标记-复制法

思路是将堆区划分出两个的空间,每次只使用一个空间,当该空间满了,就将该空间的对象复制到另一个空间上,在复制的过程,将死亡的对象丢弃复制。
这种方式存在的问题是:每次只能使用一部分空间。 根据上述的弱分代假说,我们可以不按照等比例划分空间。在目前主流的商业JVM中采用的是Appel式回收。
Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间(默认是8∶1)每次分配内存只使用Eden和其中一块Survivor
发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。 这样做存在的问题是:如果另一块Survivor不足以存储本轮GC存活的对象时,就需要依赖其他内存区域(实 际上大多就是老年代)进行分配担保(Handle Promotion)。即将这些对象移动到老年代中。
在这里插入图片描述

5. 标记-整理法

标记-复制法适合于新生代,而对于老年代来说,大部分对象都不会那么容易死亡,所以老年代建议使用标记-整理”(Mark-Compact)算法,让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存

在这里插入图片描述

3.3 HotSpot GC实现

1. 可达性分析—根节点的枚举

根节点的枚举是为了可达性分析的。目前来说,根节点的枚举过程必须暂停用户线程,为了防止在GC的过程,对象的状态发生变化
目前主流的JVM都是采用的准确式的垃圾回收策略,不需要完整遍历整个上下文和全局的引用位置,JVM通过oopMap来获取那些位置存储了引用类型数据,即JVM会记录一个KV结构:<引用名: 在类文件中的位置>,以便于快速定位。 所以,会在以下几种情况对OopMap进行操作:

  1. Java在加载类到方法区的时候,会在OopMap中记录引用的位置(地址的偏移量);
  2. 在及时编译的时候,会记录引用在在线程的栈中位置的变化。一个线程中会有多个栈帧,每个栈帧对应一个方法,GC发生时,我们不希望遍历整个栈来对引用进行根节点枚举,有了OopMap就可以直接找到对应的位置。

2. 何时进行根节点的枚举—安全点

首先明确一点有助于理解概念:安全指的是:引用关系不发生变化。 上文提到的使用OopMap来记录引用的位置,那么问题是:针对于及时编译中产生的OopMap记录,可能会频繁地修改引用,如果对所有对引用的修改都进行记录,那么开销是非常大的。
实际上,HotSpot会在一个线程到达了一个称为安全点的位置时才记录OopMap。也就是说,GC的发生不能在程序执行的任意位置,必须让程序执行到安全点才可以。 总的来说,安全点就是指,当线程运行到这类位置时,堆对象状态是确定一致的,JVM可以安全地进行操作,如GC,偏向锁解除等。 从程序的角度来看,安全点可以是一段程序中,引用关系发生变化之前的某个位置。

3. 如何让所有的线程全部跑到安全点—抢先式中断 (Preemptive Suspension)和主动式中断(Voluntary Suspension)

  1. 抢先式中断:先把所有线程中断,判断是否在安全点,如果不是再运行到安全点;
  2. 主动式中断:使用标志信息,各个线程论询这个标志,发现标志为true就运行到最近的安全点挂起。轮询标志的地方和安全点是重合的。

4. 对于没有在运行的线程怎么办?—安全区域

对于处在等待队列和同步队列中的线程,由于他们没有拿到时间片,暂时无法确保运行到安全点,那么此时JVM应该一个一个唤醒所有线程,如果当前线程不在安全点,就运行到安全点,如果在就跳过。
这个是基本思路,但是对于已经在安全点的线程,唤醒他再切换是耗时的。因此,引入了安全区的概念,只要在运行到了安全区的线程,就不去唤醒他们,节约了线程切换的开销。 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。
因此,一个线程运行到了安全区时,就标识自身进入安全区域,当他运行离开安全区时,它要检查虚拟机是否已经完成了根节点枚举,如果没有,它就必须一直等待,直到收到可以离开安全区域的信号为止

5. 跨区引用—记忆集

分代收集和部分GC都会存在在回收一个区域时,有别的区域对象中的引用指向回收区域对象的情况如果存在这种引用关系,那么该对象不能回收
使用记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构(比如数组)。 关于记忆集的实现,有以下几种参考:

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

其中卡精度是指的卡表,也是Hotspot使用的实现方式。其数据结构为字节型数组。
字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。
在这里插入图片描述如果一个卡页(对应的内存区域)有跨区域引用,那么久标记为1,此时被称为脏页,在根节点枚举时只需要查询脏页即可

6. 写屏障

在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的,即维护引用变化的正确性,在对象赋值的那一刻去更新维护卡表。 写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的的around AOP切面,
在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)

3.4 经典的GC收集器

没有最完美的GC,只有最合适的GC。我们需要做的是选择不同的GC收集器进行组合使用。

1. 新生代收集器——Serial收集器

该虚拟机强调,在发生GC时,要暂停所有的其他工作线程,直到GC结束,即“stop the world”。并且使用单线程来进行GC。
在这里插入图片描述

2. 新生代收集器——ParNew/ParNew收集器

可以看做是Serial收集器的并行版本,使用多线程来进行垃圾收集,其他的特点和Serial几乎一致。在JDK 9中取消了serial+CMS收集器的组合,因此只能使用ParNew(新生代)和CMS(老年代)的组合。
在这里插入图片描述

3. 新生代收集器——Parallel Scavenge 收集器

采用标记复制法,同时支多线程并行完成GC任务。但是和ParNew收集器不同的是,ParNew收集器注重的是减少用户的暂停时间,而Parallel Scavenge的设计目标是:达到一个可以控制的吞吐量

t h r o u g h p u t = 运 行 用 户 代 码 时 间 运 行 用 户 代 码 时 间 + 运 行 G C 时 间 throughput=\frac{运行用户代码时间}{运行用户代码时间+运行GC时间} throughput=+GC

4. 老年代收集器——CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。在及时响应的服务器上,通常采用这种GC。
如其名字描述的那样,该GC收集器是采用的标记清除算法。CMS收集器工作的过程有四个步骤:

  1. 初始标记:触发stop the world,初步标识GC roots可以直接关联的对象,速度很快;
  2. 并发标记:从GC Roots直接关联对象遍历整个GC Roots对象图,这部分是非常耗时的,但这个过程可以和用户线程并发执行;
  3. 重新标记:触发stop the world,为了修正在并发标记过程中,用户线程对对象的修改,即某些对象逃逸成功,但是不会增加新的回收对象;
  4. 并发清除:清除标记阶段(1,2,3)标记的死亡对象,这部分可以和用户线程并发执行。

在这里插入图片描述但是CMS也存在一定的缺陷:

  1. 这种并发执行会降低用户线程的执行速度,虽然不会暂停线程,但是他的重新标记步骤相较于需要stop the world的收集器来说,是冗余的操作;
  2. CMS无法处理浮动垃圾,可能会因为浮动垃圾的堆积触发一次 stop the world的full GC。浮动垃圾是在并发标记和并发清理两个阶段产生的新的回收对象。浮动垃圾会留到下一个GC回收,由于浮动垃圾占用的内存不会被使用(可以理解为下一个GC前,这部分内存暂时性的泄露),所以CMS收集器不能像其他收集器那样等待 到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。在JDK 5的默认设置下,CMS收集器当老年代使用了68%(相关参数。-XX:CMSInitiatingOccu-pancyFraction)的空间后就会 被激活。
  3. 标记清除算法会引发大量的碎片空间。CMS提供了一种方案是在触发Full GC时,进行碎片整理。

5. 全功能收集器——G1收集器

G1收集器在设计上将整个堆空间分成许多大小相同的区域(Reign),若干个Reign组成一个回收集。和之前的基于分代的设计思路不同,G1收集器关注的是回收哪块内存的收益最大(哪块的垃圾最多),这种模式成为`Mixed GC。

但是,G1收集器仍然遵循分代假说。只不过每个Regin可以扮演新生代的Eden空间、Survivor空间,或者老年代空间。Humongous空间是专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象,在低位上,该空间通常被视为老年代。
在这里插入图片描述G1收集器很多地方和CMS收集器很类似,其工作流程大致分成四个阶段:

  1. 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS
    指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要 停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际 并没有额外的停顿。
  2. 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以 后,还要重新处理SATB记录下的在并发时有引用变动的对象。
  3. 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  4. 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行 完成的。

4. 如何选择GC收集器

关注对一些重点参数的需求:

  1. 吞吐量的需求;
  2. 时延的要求;
  3. 运行的硬件基础如何;
  4. 使用的JDK版本。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值