1. 类加载

1.1. 类的生命周期

  1. Loading 加载
    • 类加载器:Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。主要有以下类加载器:
      • BootStrapClassLoader 启动类加载器,加载java_home/lib下的class
      • ExtensionClassLoader 扩展类加载器,加载java_home/lib/ext/目录下的class
      • ApplicationClassLoader 应用类加载器,加载classpath下的class
      • UserClassLoader 用户自定义的类加载器
    • 双亲委派机制
      1. 要加载一个类的时候,如果有自定义的加载器,会先去自定义加载器的缓存中查找有没有加载过这个类,如果加载过了,就返回,否则委托给父加载器ApplicationClassLoader去加载
      2. ApplicationClassLoader在自己的本地缓存中查找,如果找到了,就返回。没有的话委托给父加载器ExtensionClassLoader加载
      3. Extension在自己的本地缓存中查找,如果找到了,就返回。没有的话就委托给父加载器BootStrapClassLoader加载
      4. BootStrapClassLoader在自己的本地缓存中查找,如果找到了,就返回。没有就调用自己的findClass()方法开始在自己管理的目录下去尝试加载这个类,找到了,加载并返回。没找到就返回给子加载器ExtensionClassLoader,让它自己去加载
      5. ExtensionClassLoader调用findClass()方法,在自己管理的目录下去尝试加载这个类,找到了,加载并返回。没找到就返回给子加载器ApplicationClassLoader,让它自己去加载
      6. ApplicationClassLoader调用findClass()方法,在自己管理的目录下去尝试加载这个类,找到了,加载并返回。没找到的话,如果有自定义的加载器就交给自定义加载器加载,否则就跑ClassNotFoundException
      7. 自定义加载器找到就加载,找不到抛ClassNotFoundException
      • 双亲委派机制的作用
        1. 主要是为了保证安全,保证一些核心的类库不会被后面的篡改
        2. 其次是为了节省空间,毕竟都加载过一次了,再加载一次纯属浪费空间
      • 打破双亲委派机制
        • jdk1.2之前,loadClass可以被子类重写
        • ThreadContextClassLoader,为了实现JNDI等SPI机制。1.6提供了ServiceLoader来实现SPI机制
        • 用户程序的热部署、热替换
  2. Linking 链接
    • Verification
      • 验证文件格式是否正确
    • Preparation
      • 静态变量赋默认值
    • Resolution
      • Java虚拟机将常量池内的符号引用替换为直接引用的过程
        • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中
        • 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在
  3. Initialization 初始化
  • 初始化阶段就是执行类构造器()方法的过程。()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
  • ()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成()方法
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成()方法。但接口与类不同的是,执行接口的()方法不需要先执行父接口的()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的()方法
  • Java虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行完毕()方法。如果在一个类的()方法中有耗时很长的操作,那就可能造成多个进程阻塞 ,在实际应用中这种阻塞往往是很隐蔽的
  • 主要有以下过程: ->
    • 如果有静态变量,那么静态变量赋初始值
    • JVM虚拟机规范规定了六种情况必须对类进行初始化
      1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
        • 使用new关键字实例化对象的时候。
        • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
        • ·调用一个类型的静态方法的时候
      2. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化
      3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
      4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
      5. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化
      6. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化
  1. Using 使用
  2. UnLoading 卸载

    2. 对象

    2.1. 对象的创建

  3. 当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程

  4. 为对象分配内存的方式
    • 指针碰撞:假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这样的内存分配方式被称为指针碰撞
    • 空闲列表:如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)
    • 选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上(在CMS的实现里面,为了能在多数情况下分配得更快,设计了一个叫作Linear Allocation Buffer的分配缓冲区,通过空闲列表拿到一大块分配缓冲区之后,在它里面仍然可以使用指针碰撞方式来分配)就只能采用较为复杂的空闲列表来分配内存
    • 问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种可选方案:
      • 一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;
      • 另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local AllocationBuffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。

        2.2. 对象的内存布局

  • 对于普通对象而言,主要包括对象头(Mark Word)、类型指针(Klass Pointer)、实例数据(Instance Data)、对齐填充(Padding)四部分,如果是数组对象还需要加上数组长度

    2.2.1. 对象头

    2.2.1.1 Mark Word
  • 考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下所示。

image.png

  • 64 位虚拟机 Mark Word 是 64bit 其结构如下

image.png

  • 32位虚拟机Mark Word 其结构如下

image.png

  • 根据上面的表,当一个对象被获取了hashcode值之后,将无法进入偏向锁状态

    2.2.1.2. Klass Pointer 类型指针
  • 即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。默认情况下,虚拟机会开启指针压缩,所以这部分的大小是4个字节,如果使用 -XX:-UseCompressedClassPointers 参数的话,那么它的长度会变成8个字节。

    2.2.3. Instance Data 实例数据

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

  • 这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(OrdinaryObject Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。
  • OrdinaryObject Pointers,OOPs 在默认情况下虚拟机都会启用压缩指针,所以这部分是4个字节,但是如果使用-XX:-UseCompressedOops 参数的话,那么它的长度就会变为8个字节。

    2.2.4. Padding 对齐填充

  • 由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

    2.3. 对象的分配过程

    2.3.1. 分配过程详解

    JVM - 图4

  1. 先尝试在栈上分配,分配失败继续第二步
  2. 判断对象是否够大,如果够大直接分配到老年代,如果老年代空间不够,触发FGC。如果不够大,继续第三步
  3. 尝试在TLAB(线程本地缓冲区)分配,分配失败,继续第四步
  4. 优先在Eden区分配,如果分配失败,进行YGC,YGC期间可能会触发动态年龄判断、空间担保机制

    2.3.2. 动态年龄

    JVM并不总会等到survivor区存活的对象的年龄到达MaxTenuringThreshold值的时候,才将对象晋升到老年代

    • survivor区年龄从小到大进行累加,当加入某个年龄段后,累加和超过survivor区域*TargetSurvivorRatio的时候,就从这个年龄段网上的年龄的对象进行晋升。TargetSurvivorRatio默认50%
    • 假设现在enden区和servivor区年龄1的占用了33%,年龄2的占用了33%,累加和超过默认的TargetSurvivorRatio(50%),年龄2和年龄3的对象都要晋升。
    • 动态对象年龄判断,主要是被TargetSurvivorRatio这个参数来控制。而且算的是年龄从小到大的累加和,而不是某个年龄段对象的大小

      2.3.3. 空间担保

    • YGC期间,survivor区空间不够,把新生代的存活对象直接搬入老年代,新生代腾出的空间用来分配新对象。但不保证担保成功,即一定有足够的空间容纳从新生代晋升的对象。如果不够晋升,那么抛出OOM。

    • 空间分配担保。每次进行YGC时,JVM会计算Survivor区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次Full GC,如果小于检查HandlePromotionFailure设置,如果true则只进行Monitor GC,如果false则进行Full GC

      2.4. 对象的访问

      2.4.1. 句柄池

    • Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,如下图所示

image.png

  • 使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改

    2.4.2. 直接指针

  • Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,如下图所示

image.png

  • 使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就本书讨论的主要虚拟机HotSpot而言,它主要使用第二种方式进行对象访问(有例外情况,如果使用了Shenandoah收集器的话也会有一次额外的转发,具体可参见第3章)。

    3. Java内存模型

    3.1. 主内存与工作内存

  • Java内存模型规定了所有的变量都存储在主内存(Main Memory)中
  • 每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据
  • 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成
  • 线程、主内存、工作内存三者的交互关系如下图所示

image.png

3.2. 内存的交互操作

  1. Java内存模型中定义了以下8种操作来完成一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节
    • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
    • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
    • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
    • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
    • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
    • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
    • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
    • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
  2. 除此之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:
    • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
    • 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
    • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
    • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。
    • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
    • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。
    • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
    • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)

      3.2. volatile关键字,As-If-Serial原则

当一个变量被定义成volatile之后,它将具备两项特性

  • 第一项是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized、java.util.concurrent中的锁或原子类)来保证原子性
    • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
    • 变量不需要与其他的状态变量共同参与不变约束
  • 使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在同一个线程的方法执行过程中无法感知到这点,这就是Java内存模型中描述的所谓“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)

    • 实现原理:内存屏障
      • 字节码:对写操作,前面加StoreStore屏障,后面加SotreLoad屏障;对读操作,读前加LoadLoad屏障,读后加LoadStore屏障
      • 汇编指令:lock addl $0x0,(%esp),这个操作的作用相当于一个内存屏障(Memory Barrier或Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个处理器访问内存时,并不需要内存屏障;但如果有两个或更多处理器访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了

        3.3. long和double的特殊处理

        3.4. 原子性、可见性、有序性

        3.5. happen-before原则

        4. 运行时数据区

        4.1. 程序计数器-线程私有

  • 用来记录当前线程执行到的指令的行号指示器。为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

    4.2. Java虚拟机栈-线程私有

  • 虚拟机栈为虚拟机执行Java方法(也就是字节码)服务。每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈无法申请到足够的内存会抛出OutOfMemoryError异常

    4.2.1. 栈帧

    • 局部变量表
    • 操作数栈
    • 动态链接
    • 方法出口,返回地址

      4.2.2. 常见字节码指令

      4.3. 本地方法栈-线程私有

  • 本地方法栈则是为虚拟机使用到的本地(Native)方法服务。本地方法栈也会在栈深度溢出或者栈申请空间失败时分别抛出StackOverflowError和OutOfMemoryError异常

    4.4. 运行时常量池-共享

  • Class文件中的常量池表(用于存放编译期生成的各种字面量与符号引用)在类加载后存放到方法区的运行时常量池中。是方法区的一部分,受方法区大小限制,当无法申请到空间时,抛出OutOfMemoryError异常

    4.5. Java堆-共享

  • 在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”(因为有一些可能会在栈上分配,逃逸分析,标量替换)所有的对象实例都在这里分配内存。

    4.6. 方法区-共享

  • 用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据

  • 不同版本的区别
    • JDK1.8之前这部分的实现是Permanent Generation永久代,在1.8之后,这部分的实现是Meta Space区;
    • 1.8之前FullGC不会对永久代进行垃圾回收,但是1.8之后会对Meta Space进行回收
  • 方法区的回收

    • 方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型
    • 废弃常量的回收判定条件
      • 已经没有任何对象引用常量池中的常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个常量就将会被系统清理出常量池
    • 判定一个类型是否属于“不再被使用的类”的条件
      • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
      • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的
      • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
    • 关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版 [1] 的虚拟机支持。
    • 在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力

      4.7. 直接内存

  • 在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据

  • 一般会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常

    5. 垃圾回收

    5.1. 哪些对象是垃圾

    判断对象是不是无用的垃圾对象主要有两种方式:

    5.1.1. 引用计数法

    • 在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一,任何时刻计数器为零的对象就是不可能再被使用的
    • 但是这种无法解决循环引用的问题

      5.1.2. 根可达法

    • 通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的

    • 可以作为GC Roots的对象有哪些
      • 虚拟机栈(栈帧中的本地变量表)中引用的对象
      • 方法区中的类静态属性引用的对象
      • 方法区中常量引用的对象
      • 本地方法栈中JNI(即一般说的Native方法)中引用的对象
      • 所有被同步锁持有的对象
      • 一些Class对象
      • 其他动态加入的一些对象

        5.2. 垃圾回收算法

        5.2.1. 标记清除

        5.2.2. 拷贝

        5.2.3. 标记压缩

        5.2.4. 分代回收

        5.3. 垃圾回收器

        5.3.1. 算法

  • 根节点枚举

  • OopMap
    • 保守式
    • 半保守式
    • 准确式
  • 安全点与安全区域
    • 安全点:安全点就是指代码中的一些特定的位置,当线程运行到这些位置的时候它的状态是确定的,那么JVM就可以安全的进行一些操作,比如GC等。所以GC不是想什么时候触发就会立即触发的,是需要所有的线程都到达安全点以后才会触发。安全点的位置有下面几种
      • 方法返回之前
      • 调用某个方法结束后
      • 抛出异常的位置
      • 循环的末尾
    • 安全区域:安全点是对正在执行的线程设定的,如果一个线程处于sleep或者中断状态,它就不能相应JVM的中断请求,再运行到safe point上面。因此JVM引入了安全区,安全区是指一段代码片段中引用关系不会发生变化,在这个区域的任何一点发生GC都是安全的。
  • Remember Set & Card Table
  • 写屏障
  • 并发的可达性分析-三色标记算法
    • 三色标记
      • 解释:
      • 问题
      • 解决
        • CMS-Increment Update
        • G1-SATB
  • 垃圾回收分类
    • MinorGC
      • 清理全部的年轻代
    • MixedGC
      • 清理全部的年轻代和部分老年代,目前只有G1支持这种模式
    • MajorGC
      • 清理全部的老年代,但是一般情况下,对老年代进行清理时,都会进行FullGC
    • FullGC
      • 在整个堆范围内,进行垃圾回收。G1和CMS不提供FullGC,它们的FullGC最终是通过Serial Old进行的
  • OopMap
  • Card Table
  • 写屏障
    • 在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。
    • 垃圾回收里的“写屏障”,垃圾回收里的“读屏障”与解决并发乱序执行问题中的“内存屏障”区分开来,避免混淆。
    • 写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面 ,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。
    • 在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。
    • HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障。
  • 并发表及的误标和漏标的条件
    • 赋值器插入了一条或多条从黑色对象到白色对象的新引用
    • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
  • CMS增量更新
    • 破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次
  • G1原始快照

    • 破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索

      5.3.1 Serial & Serial Old

  • 单线程的年轻代和老年代回收器

    5.3.2. Parallel Scavenge & arallel Old

  • Parallel Scavenge:Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得我们关注。这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)

  • 自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性

    5,3,3, ParNew & CMS

  • 写屏障

    • CMS的写屏障:增量更新。增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次

      5.3.4. G1

  • Collection Set

  • Remember Set
    • G1的Remeber Set也被称为“双向”Card Table结构。相较于之前垃圾回收器的Card Table而言,除了包括”我指向谁”之外,还多了一个”谁指向我”。比原来的卡表实现起来更复杂
    • 缺点
      • 由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作

  • 三色标记-SATB-写屏障
  • 写屏障:

    • G1的写屏障,原始快照。破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索

      5.3.5. ZGC

      5.3.6. Shenandoah

      5.4. GC 垃圾回收期参数与调优

      5.4.1. 调优的目标

  • 吞吐量

    • 用户代码时间 /(用户代码执行时间 + 垃圾回收时间)
  • 响应时间
    • STW越短,响应时间越好

      5.4.1. GC问题排查流程

  1. 运维团队发现CPU/内存占用飙高
  2. 使用top命令查看占用率高的进程
  3. 使用top -Hp pid命令查看占用率高的线程id
  4. 使用jstack pid 命令查看进程的所有线程堆栈信息,然后搜索指定的线程,注意top命令展示的线程id是10进制的,而jstack命令展示的nid是16进制的
  5. 如果是死锁问题,一般用jstack命令就可以查到了。重点关注状态是WAITING、BLOCKED的线程。
    • 死锁的话jstack会直接提示出来

image.png

  • 如果是一个线程持有一把锁长时间不释放的话,那么其他的线程就会阻塞在这个锁对象上,对应的其他线程的阻塞等待信息就会有这个对象的地址信息

image.png

  1. jstat -gc pid 1000 每秒输出一次内存的信息
  2. jmap -histo pid | head 20 查找堆中对象数量最多的前20个类信息
  3. 然后根据展示的信息,找到可能出问题的类,再根据类信息查看代码,看看是否哪里出了BUG

    5.4.*. 常见问题解决

  4. 可能是服务器内存不够,有时候扩大内存,更换垃圾回收器就能解决一些频繁FGC的问题,具体问题具体分析

    5.4.2. 常见垃圾回收器调优方案

    5.4.2.1. Parallel
  • 常见参数

    • -XX:SurvivorRatio Eden和Survivor的比例,默认8:1:1
    • -XX:PreTenureSizeThreshold 大对象到底多大
    • -XX:MaxTenuringThreshold 对象从年轻代晋升到老年代的年龄
    • -XX:+ParallelGCThreads 并行收集器的线程数,同样适用于CMS,一般设为和CPU核数相同
    • -XX:MaxGCPauseMillis(更关注最大停顿时间)暂停时间
    • -XX:GCTimeRatio(更关注吞吐量)应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间
    • GC时间占总时间的比率,默认值为99,即允许1%的GC时间。仅在使用 Parallel Scavenge收集器时生效
    • -XX:+UseAdaptiveSizePolicy 自动选择各区大小比例,这是PS区别于ParNew的地方,可以自适应
      5.4.2.2. CMS
  • 常见参数

    • -XX:+UseConcMarkSweepGC
    • -XX:ParallelCMSThreads CMS线程数量
    • -XX:CMSInitiatingOccupancyFraction 使用多少比例的老年代后开始CMS收集,默认是68%(近似值),如果频繁发生SerialOld卡顿,应该调小,(频繁CMS回收)
    • -XX:+UseCMSCompactAtFullCollection 设置CMS 收集器在完成垃圾收集后是否要进行一次内存碎片整理。仅在使用 CMS 收集器时生效,此参数从JDK 9开始废弃
    • -XX:CMSFullGCsBeforeCompaction 默认0,设置CMS收集器在进行若干次垃圾收集后再启动一次内存碎片整理。仅在使用 CMS 收集器时生效,此参数从JDK 9开始废弃
    • -XX:+CMSClassUnloadingEnabled
    • -XX:CMSInitiatingPermOccupancyFraction 达到什么比例时进行Perm回收
    • GCTimeRatio 设置GC时间占用程序运行时间的百分比
    • -XX:MaxGCPauseMillis 停顿时间,是一个建议时间,GC会尝试用各种手段达到这个时间,比如减小年轻代
    • -XX:+CMSScavengeBeforeRemark 在CMS GC前启动一次ygc,目的在于减少old gen对ygc gen的引用,降低remark时的开销——-一般CMS的GC耗时 80%都在remark阶段
  • 常见问题及解决思路

    • 最终标记阶段停顿时间过长问题
      • CMS的GC停顿时间约80%都在最终标记阶段(Final Remark),若该阶段停顿时间过长,常见原因是新生代对老年代的无效引用,在上一阶段的并发可取消预清理阶段中,执行阈值时间内未完成循环,来不及触发Young GC,清理这些无效引用通过添加参数:-XX:+CMSScavengeBeforeRemark。在执行最终操作之前先触发Young GC,从而减少新生代对老年代的无效引用,降低最终标记阶段的停顿,但如果在上个阶段(并发可取消的预清理)已触发Young GC,也会重复触发Young GC
    • 并发模式失败(concurrent mode failure) & 晋升失败(promotion failed)问题
      1. 并发模式失败:当CMS在执行回收时,新生代发生垃圾回收,同时老年代又没有足够的空间容纳晋升的对象时,CMS 垃圾回收就会退化成单线程的Full GC。所有的应用线程都会被暂停,老年代中所有的无效对象都被回收
      • 晋升失败:当新生代发生垃圾回收,老年代有足够的空间可以容纳晋升的对象,但是由于空闲空间的碎片化,导致晋升失败,此时会触发单线程且带压缩动作的Full GC

    并发模式失败和晋升失败都会导致长时间的停顿,常见解决思路如下:

    1. - 相关参数
    2. - XX:CMSInitiatingOccupancyFraction 当堆内存使用达到多少的时候启动CMS进行垃圾回收,默认近似68%
    3. - -XX:ConcGCThreads 并发GC的线程数
    4. - 降低触发CMS GC的阈值,即参数-XX:CMSInitiatingOccupancyFraction的值,让CMS GC尽早执行,以保证有足够的空间
    5. - 增加CMS线程数,即参数-XX:ConcGCThreads
    6. - 增大老年代空间
    7. - 让对象尽量在新生代回收,避免进入老年代
    • Memory Fragmentation 内存碎片问题
      • 相关参数
        • -XX:+UseCMSCompactAtFullCollection 在每次进行CMS的时候进行碎片整理,可能影响效率
        • -XX:CMSFullGCsBeforeCompaction 默认为0 指的是经过多少次FGC才进行压缩
      • 通常CMS的GC过程基于标记清除算法,不带压缩动作,导致越来越多的内存碎片需要压缩,常见以下场景会触发内存碎片压缩:
        • 新生代Young GC出现新生代晋升担保失败(promotion failed)
        • 程序主动执行System.gc()
      • 解决方案:
        • 可通过参数CMSFullGCsBeforeCompaction的值,设置多少次Full GC触发一次压缩,默认值为0,代表每次进入Full GC都会触发压缩,带压缩动作的算法为上面提到的单线程Serial Old算法,暂停时间(STW)时间非常长,需要尽可能减少压缩时间
          5.4.2.3. G1
  • 常见参数

    • -XX:+UseG1GC
    • -XX:MaxGCPauseMillis 建议值,G1会尝试调整Young区的块数来达到这个值
    • -XX:GCPauseIntervalMillis ?GC的间隔时间
    • -XX:+G1HeapRegionSize 分区大小,建议逐渐增大该值,1 2 4 8 16 32。 随着size增加,垃圾的存活时间更长,GC间隔更长,但每次GC的时间也会更长 ZGC做了改进(动态区块大小)
    • G1NewSizePercent 新生代最小比例,默认为5%
    • G1MaxNewSizePercent 新生代最大比例,默认为60%
    • GCTimeRatio GC时间建议比例,G1会根据这个值调整堆空间
    • ConcGCThreads 线程数量
    • InitiatingHeapOccupancyPercent 启动G1的堆空间占用比例,非yong区,包括old+humongous区
    • -XX:G1HeapWastePercent:默认5%。gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了
  • 常见问题及解决思路
    • Full GC问题
      • G1的正常处理流程中没有Full GC,只有在垃圾回收处理不过来(或者主动触发)时才会出现, G1的Full GC就是单线程执行的Serial old gc,会导致非常长的STW,是调优的重点,需要尽量避免Full GC,常见原因如下:
        • 程序主动执行System.gc()
        • 全局并发标记期间老年代空间被填满(并发模式失败)
        • Mixed GC期间老年代空间被填满(晋升失败)
        • Young GC时Survivor空间和老年代没有足够空间容纳存活对象
      • 常见的解决是:
        • 增大-XX:ConcGCThreads=n 选项增加并发标记线程的数量,或STW期间并行线程的数量:

-XX:ParallelGCThreads=n

  1. - 减小-XX:InitiatingHeapOccupancyPercent 提前启动标记周期
  2. - 增大预留内存 -XX:G1ReservePercent=n ,默认值是10,代表使用10%的堆内存为预留内存,当Survivor区域没有足够空间容纳新晋升对象时会尝试使用预留内存
  • 巨型对象分配
    • 巨型对象区中的每个Region中包含一个巨型对象,剩余空间不再利用,导致空间碎片化,当G1没有合适空间分配巨型对象时,G1会启动串行Full GC来释放空间。
    • 可以通过增加 -XX:G1HeapRegionSize来增大Region大小,这样一来,相当一部分的巨型对象就不再是巨型对象了,而是采用普通的分配方式
  • 不要设置Young区的大小
    • 原因是为了尽量满足目标停顿时间,逻辑上的Young区会进行动态调整。如果设置了大小,则会覆盖掉并且会禁用掉对停顿时间的控制
  • 平均响应时间设置
    • 使用应用的平均响应时间作为参考来设置MaxGCPauseMillis,JVM会尽量去满足该条件,可能是90%的请求或者更多的响应时间在这之内, 但是并不代表是所有的请求都能满足,平均响应时间设置过小会导致频繁GC