Skip to content

《深入理解虚拟机》笔记

Posted on:2019年4月28日

Table of Contents

Open Table of Contents

内存区域

对象创建

对象创建流程

内存分配方式:指针碰撞和空闲列表(分配方式取决于 Java 堆是否规整,即垃圾收集器是否否带有空间压缩整理(Compact)的能力决定)

堆上内存分配线程安全问题

  1. 采用 CAS 配上失败重试的方式保证更新操作的原子性
  2. TLAB:每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲

对象内存布局

对象内存布局

垃圾回收器与内存分配

GC Root

  1. 在虚拟机栈中引用的对象,例如参数、局部变量、临时变量
  2. 在方法去中类的静态属性引用的对象,例如 Java 类的引用类型静态变量
  3. 在方法区中常量引用的对象,例如字符串常量池里的引用
  4. 在本地方法栈中 JNI 引用的对象
  5. Java 虚拟机内部引用的对象,例如基本类型对应的 Class 对象
  6. 所有被同步锁(Synchronized 关键字)持有的对象
  7. 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等

垃圾回收器组合

垃圾回收器组合

  1. Serial 收集器

    • HotSpot 虚拟机在客户端模式下的默认新生代收集器
    • 简单高效(与其他收集器的单线程相比)
    • 对内存受限环境,是所有收集器里额外内存消耗最少的
    • 对单核处理器或核心数较少的环境,无线程交互开销,可以获得最高的单线程收集效率
  2. ParNew 收集器

    • Serial 收集器的多线程并行版本
    • 除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作,可以理解为 ParNew 合并入 CMS
  3. Parallel Scavenge 收集器

    • 并行收集
    • 吞吐量优先
  4. Serial Old 收集器

    • 是 Serial 收集器的老年代版本
    • 单线程收集器
  5. Parallel Old 收集器

    • Parallel Scavenge 收集器的老年代版本
    • 在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑 Parallel Scavenge + Parallel Old
  6. CMS 收集器

    • 获取最短回收停顿时间
    • 在并发阶段,占用了部分线程导致程序变慢,降低总吞吐量,特别是核心数量不足四个时,影响很大
    • 无法处理浮动垃圾,导致 STW
    • 基于“标记-清除”算法实现,收集结束时会存在大量空间碎片,不利于大对象分配
  7. G1 收集器

    • 可以指定最大停顿时间
    • 分 Region 的内存布局
    • 按收益动态确定回收集
    • G1 无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比 CMS 要高

常用参数

参数描述
UseSerialGC虚拟机运行在 Client 模式下的默认值,打开此开关后,使用 Serial + Serial Old 的收集器组合进行内存回收
UseParNewGC打开此开关后,使用 ParNew + Serial Old 的收集器组合进行内存回收
UseConcMarkSweepGC打开此开关后,使用 ParNew + CMS + Serial Old 的收集器组合进行内存回收。Serial Old 收集器将作为 CMS 收集器出现 Concurrent Mode Failure 失败后的后备收集器使用
UseParallelGC虚拟机运行在 Server 模式下的默认值,打开此开关后,使用 Parallel Sacvenge + Serial Old(PS MarkSweep)的收集器组合进行内存回收
UseParallelOldGC打开此开关后,使用 Parallel Scavenge + Parallel Old 的收集器组合进行内存回收
SurvivorRatio新生代中 Eden 区域与 Survivor 区域的容量比值,默认为 8,代表 Eden:Survivor=8:1
PretenureSizeThreshold直接晋升到老年代的对象大小,设置后大于这个参数的对象直接在老年代分配
MaxTenuringThreshold晋升到老年代的对象年龄,每个对象在坚持过一次 Minor GC 之后,年龄就增加 1,当超过这个参数值时就进入老年代
UseAdptiveSizePolicy动态调整 Java 堆中各个区域的大小以及进入老年代的年龄
HandlePromotionFailure是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个 Eden 和 Survivor 区的所有对象都存活的极端情况
ParallelGCThreads设置并行 GC 时进行内存回收的线程数
GCTimeRatioGC 时间占总时间的比率,默认值为 99,即允许 1% 的 GC 时间,仅在使用 Parallel Scavenge 收集器时生效
MaxGCPauseMillis设置 GC 的最大停顿时间,仅在使用 Parallel Scavenge 收集器时生效
CMSInitiatingOccupancyFraction设置 CMS 收集器在老年代空间被使用多少后触发垃圾收集。默认值为 68%,仅在使用 CMS 收集器时生效
UseCMSCompactAtFullCollection设置 CMS 收集器在完成垃圾收集后是否要进行一次内存碎片整理。仅在使用 CMS 收集器时生效
CMSFullGCsBeforeCompaction设置 CMS 收集器在进行若干次垃圾收集后再启动一次内存碎片整理。仅在使用 CMS 收集器时生效
UseG1GC使用 G1 收集器,这个是 JDK 9 后的 Server 模式默认值
G1HeapRegionSize设置 Region 大小,并非最终值
MaxGCPauseMillis设置 G1 收集过程目标时间,默认值是 200ms,不是硬性条件
G1NewSizePercent新生代最小值,默认值是 5%
G1MaxNewSizePercent新生代最大值,默认值是 60%
ParallelGCThreads用户线程冻结期间并行执行的收集器线程数

内存分配规则

  1. 对象优先在 Eden 区分配
  2. 大对象直接进入老年代:大于 -XX:PretenureSizeThreashold 直接在老年代分配
  3. 长期存活对象进入老年代: 年龄等于 -XX:MaxTenuringThreshold 晋升老年代
  4. 动态对象年龄判定:如果 Survivor 空间相同年龄所有对象大小总和大于 Survivor 空间的一半,年龄大于等于该年龄的对象直接晋升老年代
  5. 空间分配担保:在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象总空间。如果 HandlePromotionFailure 允许担保失败,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC;如果小于或者 HandlePromotionFailure 不允许冒险,那这时也要改为进行一次 Full GC

内存模型

Java 的内存模型的主要目标是定义程序中各个变量的访问规则,即虚拟机中将变量存储到内存和从内存中取出变量的底层细节。

Java 的内存模型规定所有的变量(与 Java 编程中变量有区别)都存储在主内存,每条线程都有自己的工作内存。

线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

不同的线程之间也无法直接访问对方工作线程中的变量。线程间变量的传递均需要通过主内存来完成。

内存 8 种原子交互操作(对于 long 和 double 划分为两次 32 位操作,非原子性)

原子交互操作

volatile 只能保证可见性,在不符合以下规则的场景中,仍要使用加锁来保证原子性

  1. 运算结果并不依赖变量的当前值,或者能确保只有单一线程修改变量的值
  2. 变量不需要与其他状态变量共同参与不变约束
    private static volatile int race = 0;

    private static final int THREADS_COUNT = 20;

    private static void increase() {
        // 读取i
        // 值+1
        // 写回主内存
        race++;
    }

    public static void main(String[] args) {
        for (int i = 0; i < THREADS_COUNT; i++) {
            new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    increase();
                }
            }).start();
        }
        // idea 多一条线程
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        // 结果不为 200000
        System.out.println(race);
    }

volatile 通过插入内存屏障的方式禁止指令重排

线程安全

定义:当多个线程访问一个对象时,如果不考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都能得到正确的结果,那么这个对象就是线程安全的。

Java 中的线程安全

  1. 不可变

    例如:String、枚举

  2. 绝对线程安全

    在 Java API 中标注自己是线程安全的类,大多数都不是绝对的线程安全

  3. 相对线程安全

    通常意义上所讲的线程安全,它需要保证对这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性

  4. 线程兼容

    指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用

  5. 线程对立

    指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。由于 Java 语言天生就支持多线程的特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。

线程安全实现方式

  1. 互斥同步

    同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量 (Mutex)和信号量(Semaphore)都是常见的互斥实现方式。

    互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步;从解决问题的方式上看,互斥同步属于一种悲观的并发策略。

    虚拟机层面:

    synchronized,编译后同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令。这两个字节码指令都需要一个 reference 类型的参数来指明要锁定和解锁的对象

    类库层面:

    Lock 接口,相对 synchronized 增加特性:等待可中断公平锁锁绑定多个条件

    不使用 Lock 特性的场景下,优先使用 synchronized,基于以下三点:

    • synchronized 是在 Java 语法层面的同步,足够清晰,也足够简单
    • 使用 synchronized 的话则可以由 Java 虚拟机来确 保即使出现异常,锁也能被自动释放
    • Java 虚拟机更容易针对 synchronized 来进行优化,因为 Java 虚拟机可以在线程和对象的元数据中记录 synchronized 中锁的相关信息,而使用 J.U.C 中的 Lock 的话,Java 虚拟机是很难得知具体哪些锁对象是由特定线程锁持有的
  2. 非阻塞同步

    基于冲突检测的乐观并发策略,这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步

    CAS 操作带来”ABA”问题,JUC 为了解决这个问题提供了一个带有标记的原子引用类 AtomicStampedReference,即加版本号

    大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更为高效

  3. 无同步方案

    如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的。例如:可重入代码、线程本地存储

锁优化

对象头

疑问

  1. 轻量级锁解锁时为什么可能会失败?

    线程 1 持有轻量级锁

    线程 2 尝试获取锁,会将轻量级锁升级为重量级锁;初始化了 ObjectMonitor 并将 Mark Word 指向了 ObjectMonitor 的地址,所以 CAS 替换失败

  2. 指向重量级锁的指针怎么理解?

    每个对象都有一个 monitor 对象与之关联,重量级锁时对象头会有个指针指向这个 monitor 对象。ObjectMonitor 源码

    ObjectMonitor() {
       // 记录无锁状态的Mark Word
       _header       = NULL;
       _count        = 0;
       // 等待锁的线程个数
       _waiters      = 0,
       // 记录重入次数
       _recursions   = 0;
       // 存储锁对象
       _object       = NULL;
       // 表示拥有该锁的线程
       _owner        = NULL;
       // 环形双向链表,调用wait() 后的线程会存储到这
       _WaitSet      = NULL;
       _WaitSetLock  = 0 ;
       _Responsible  = NULL ;
       _succ         = NULL ;
       // 栈结构,多线程竞争锁会先存到这个列表
       _cxq          = NULL ;
       FreeNext      = NULL ;
       // 双向链表,存储竞争锁失败的线程
       _EntryList    = NULL ;
       _SpinFreq     = 0 ;
       _SpinClock    = 0 ;
       OwnerIsThread = 0 ;
       _previous_owner_tid = 0;
    }
  3. 为什么会有 cxq 和 _EntryList 两个列表来放线程?

    这是由于我们需要频繁的释放和获取锁,当我们获取锁失败那么将需要把线程放入竞争列表中,当唤醒时需要从竞争列表中获取线程唤醒获取锁,而如果我们只用一个列表来完成这件事,那么将会导致锁争用导致 CPU 资源浪费且影响性能,这时我们独立出两个列表,其中 cxq 列表用于竞争放入线程,而 entrylist 用于单线程唤醒操作。

  4. 轻量级锁解锁时如何找到挂起线程?

    • 线程竞争锁失败后 CAS 放入 cxq 列表中

      解锁的线程会唤醒之前等待的线程

    • 线程释放锁后将根据策略来唤醒 cxq 或者 entrylist 中的线程(解锁默认策略 Qmode=0:先判断 entrylist 是否为空,如果不为空,则取出第一个唤醒,如果为空再从 cxq 里面获取第一个唤醒)

    • 默认策略下优先唤醒 entrylist 列表中的线程,因为唤醒线程对象的操作是单线程的,也即只有获取锁并且释放锁的线程可以操作,所以操作 entrylist 是线程安全的

    • 如果 entrylist 列表为空,那么将会 CAS 将 cxq 中的等待线程一次性获取到 entrylist 中并开始逐个唤醒

    • 唤醒默认策略 Policy=2:从 _waitSet 头部拿节点,判断 entrylist 是否为空,为空就放入 entrylist 中,否则放入 cxq 队列排头位置

  5. 轻量级锁被线程 1 占有时,线程 2 去获取锁时是否需要自旋?此时锁是否直接膨胀为重量级锁还是自旋失败后再膨胀?

    轻量级锁 CAS 失败并不会自旋而是直接膨胀成重量级锁

    在升级成重量级锁之后,线程如果没有争抢到锁,会进行一段自旋等待锁的释放


参考

  1. 别再和面试官说 Synchronized 轻量级锁自旋了,错了!牛客网 (nowcoder.com)
  2. 两道 BT 的多线程经典面试题 - 知乎 (zhihu.com)
  3. Java Synchronized 重量级锁原理深入剖析上(互斥篇) - 掘金 (juejin.cn)

Previous Post
ClickHouse 集群搭建