JVM

JVM-learn

深入理解 JVM 虚拟机

Posted by shihunyewu on November 17, 2018

参考书籍为《深入理解 Java 虚拟机》

第 2 章 Java内存区域与内存分配策略

2.2 运行时的数据区域

  • 方法区
  • 虚拟机栈
  • 本地方法栈
  • Java 堆
  • 程序计数器

2.2.1 程序计数器

当前线程所执行的字节码的行号指示器。 JVM 的多线程中每个线程都有一个独立的程序计数器,它们互不影响,独立存储。这类内存区域被称为”线程私有”的内存。

2.2.2 Java 虚拟机栈

也是线程私有的,声明周期和线程相同。

2.2.3 本地方法栈

本地方法栈为虚拟机使用到的 Native 方法服务

2.2.4 Java 堆

所有线程共享,虚拟机启动时创建,唯一目的是存放对象实例。 Java 堆是垃圾收集器管理的主要区域。有时也被称为 GC 堆。

如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。

2.2.5 方法区

各个线程共享的内存区域。 保存已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。被称为”非堆”。 垃圾回收行为在这个区域较少出现。内存回收主要是对常量池和类型卸载。

2.2.6 运行时常量池

是方法区的一部分,编译期间生成的各种字面量和符号引用。

2.2.7 直接内存

不是虚拟机运行时数据区的一部分。这部分内存被频繁使用,而且也可能导致 OutOfMemoryError 异常出现。这部分内存很明显受运行机器的影响。

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

3.2 对象已死?

3.2.1 引用计数算法

引用计数器,被引用,加 1;引用失效,计数器值减 1;计数器值为 0 的对象不可能被使用。

** 缺点**:很难解决对象之间的相互循环引用的问题。

3.2.2 根搜索算法

GC Roots 的对象作为起点,当某一个对象到 GC Roots 没有任何引用链相连(即从 GC Roots 到这个对象不可达),证明此对象是不可用的。

可以作为 GC Roots 的对象包括下面几种:

  • 虚拟机栈中引用的对象
  • 方法区中的类静态属性引用的对象
  • 方法区中的常量引用的对象
  • 本地方法栈中 JNI 的引用对象

3.2.3 再谈引用

有些对象属于”食之无味,弃之可惜”。只是单纯定义引用和没被引用,太过狭隘,因此 Java 在 JDK 1.2 之后,对引用的概念进行了扩充。

将引用分为强引用、软引用、弱引用、虚引用四种。

  • 强引用:类似于”Object obj = new Object()”,只要强引用存在,垃圾收集器永远不会回收掉引用的对象。
  • 软引用,对象有用但是非必需。在系统将要发生内存溢出之前才会尝试去回收,如果回收了之后仍然没有足够内存,这时候才抛出内存异常。
  • 弱引用,只能生存岛下一次垃圾收集发生之前。无论内存是否足够都会被回收。
  • 虚引用,只是为了对象在被收集器回收时收到一个系统通知。

3.2.4 生存还是死亡

对象在被回收之前会经历两次标记过程:

  • 发现该对象没有被 GC Roots 引用。第一次标记并且进行筛选。

3.2.5 回收方法区

判断一个常量是否是”废弃常量”比较简单,而要判断一个类是否是”无用的类”需要符合下面三个条件

  • 该类所有的实例都已近被回收
  • 加载该类的 ClassLoader 已经被回收
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法再任何地方通过反射访问该类的方法。
常用场景

在大量使用反射、动态代理、CGLib等bytecode框架的场景,以及动态生成 JSP 和 OSGI 这类频繁自定义 ClassLoader 的场景都需要类卸载功能,保证方法区内存不会溢出。

3.3 垃圾收集算法

  • 3.3.1 标记-清除算法
  • 3.3.2 复制算法
  • 3.3.3 标记-整理算法
  • 3.3.4 分代收集

3.4 垃圾收集器

  1. 新生代串行收集器
  2. 新生代并行收集器
  3. Parallel Scavenge 收集器
    • 目标达到一个可控制吞吐量
  4. 老年代串行收集器
  5. 老年代并行收集器
  6. CMS 垃圾收集器
    • 过程
      • 初始标记,并行标记 GC Roots 能够直接关联到的对象,速度很快,需要停顿
      • 并发标记
      • 重新标记,为了修正并发标记期间的因为用户程序继续运作而导致标记产生的那部分对象的标记记录,需要停顿
      • 并发清除,不需要停顿
    • 缺点
      • 吞吐量低,低停顿时间是以牺牲吞吐量为代价的
      • 无法处理浮动垃圾,比如在并发清除过程中会再产生垃圾
      • 标记清除算法导致空间碎片,出现老年代空间剩余,不得不提前出发 Full GC
  7. G1 收集器
    • 将堆划分成多个大小相等的独立区域,新生代和老年代不再物理隔离。
    • 通过记录每个区间垃圾回收时间和回收锁获得的空间(过去的经验值),维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的区域。
    • 标记整理收集方法

3.5 回收策略

Minor GC 和 Full GC

  • Minor GC:回收新生代,速度较快
  • Full GC:回收老年代和新生代,老年代存活时间长,因此 Full GCC 很少执行

内存分类策略

  1. 对象优先在 Eden 空间分配,空间不够时,发起 Minor GC
  2. 大对象直接进入老年代,设置 JVM 参数,将大于阈值的对象直接在老年代分配
  3. 长期存活的对象进入老年代
    • 为对象定义年龄计数器,到了年龄就会进入老年代
    • 如何累积年龄?
      • 每次 Minor GC 的时候,对象被复制到 Survivor 区,年龄累加 1
  4. 动态对象年龄判定
    • 当 Survivor 中相同年龄的所有对象的总和大小大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄
  5. 空间分配担保
    • Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,那么 Minor GC 可以认为是安全的。
      • Minor GC 中是会有对象从新生代进入老年代的,最极端的情况下是所有的新生代对象都进入老年代,如果能够保证老年代中连续可用的空间是大于新生代的所有对象的空间的话,说明足够承受 Minor GC 过程中的晋升到老年代的对象。所以可以认为是安全的。
      • 目标就是尽量不启动 Full GC
    • 不成立的话
      • 检查 HandlePromotionFailure 的值是否允许担保失败
        • 如果允许那么就继续检查老年代最大的可用的连续空间是否大于历次晋升到老年代对象的平均大小,这是一个经验值。
          • 如果大于,尝试进行一次 Minor GC
          • 如果小于,尝试进行一次 Full GC
        • 如果不允许,尝试进行一次 Full GC

Full GC 的触发条件

  1. 调用 System.gc()
  2. 老年代空间不足
    • 老年代空间不足的场景有大对象直接进入老年代、长期存活的对象进入老年代等。
      • 尽量不要创建过大的对象以及数组
      • 可以通过调整 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉。
      • 调大 XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代中多存在一段时间(虽然 JDK7及之后已经不再判断这个参数)
  3. 空间担保失败
  4. JDK1.7 及之前的永久代空间不足
    • 如果加载的类、反射的类和调用的方法较多时,永久代可能会被占满,产生 Full GC。
      • 增大永久代空间
      • 使用 CMS GC
  5. Concurrent Mode Failure
    • ???不懂

第 6 章 类文件结构

6.3 Class 类文件的结构

Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储,这种伪结构只有两种数据类型:无符号数和表。

6.3.1 魔数与 Class 文件的版本

每个 Class 文件的头 4 个字节被称为魔数(Magic Number),唯一作用是用于确定这个文件是否是 Class 文件。Class 文件的魔数为 0xCAFEBABE。 紧接着魔数的 4 个字节存储的是 class 文件的版本号:第 5 和 第 6 个字节是次版本号,第 7 和第 8 个字节是主版本号。java 的版本号是从 45 开始的。

6.3.2 常量池

第 7 章 虚拟机类加载机制

7.2 类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载七个阶段。 验证、准备和解析被称为连接。

  1. 主动引用
    • 遇到 new getstatic、putstatic、invokestatic 这四条字节码指令时,如果类没有进行初始化,必须先触发其初始化
      • new,实例化对象的时候
      • getstatic,putstatic,invokestatic 读取、设置一个类的静态字段,调用一个类的静态方法
    • 使用 java.lang.releckt 包对类进行反射调用的时候。
    • 初始化一个类的时候,发现其父类还没有进行过初始化,需要先触发父类的初始化。
    • 当虚拟机启动时,用户需要指定一个需要执行的主类,虚拟机会优先初始化这个主类。
    • java.lang.invoke.MethodHandle 实例的最后解析结果是 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法对应的类没有进行初始化,则需要先触发其初始化。
      • 怎么判断解析结果??
  2. 被动引用 主动引用会触发类初始化,而被动引用不会,常见的被动引用包括
    • 通过子类引用父类的静态字段,不会导致子类初始化
    • 通过数组来引用类,不会触发此类的初始化。数组类会被初始化,数组类中的对象不会。
    • 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到常量的类,因此不会触发定义常量的类的初始化。

      7.3 类加载过程

      包含了加载、验证、准备、解析和初始化 5 个过程

  3. 加载
    • 通过类的完全限定名获取定义该类的二进制字节流
      • 二进制字节流的形式从 ZIP 包读取
      • 从网络中获取
      • 运行时计算生成(动态代理技术)
      • 由其他文件生成(比如 JSP 文件生成对应的 Class 类)。
    • 该字节流表示的静态存储结构转换为方法区的运行时存储结构
    • 在内存中生成一个代表该类的 Class 对象,作为方法区中该类各种数据的访问入口
  4. 验证
    • 确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不危害虚拟机自身的安全。
  5. 准备
    • 类变量是被 static 修饰的变量,准备阶段为类变量分配内存并设置初始值,使用的是方法区的内存
      • 实例变量不会在此步骤中分配内存,实例变量会在对象实例化时随着对象一起被分配在堆中。
      • 实例化不是类加载过程中,类加载只执行一次,实例化可以进行多次。
        • 初始值一般是 0 值,而不是所赋的字面值
        • 如果是常量,将初始化为表达式所定义的值而不是 0
  6. 解析
    • 将常量池的符号引用替换为直接引用的过程
    • 解析过程再某些情况下可以在初始化阶段之后再开始,这是为支持 Java 的动态绑定
  7. 初始化
    • 正式执行类中定义的 Java 程序代码
    • 虚拟机执行 <clinit>() 方法的过程。
      • 类变量重新赋值,根据程序员通过程序制定的主观计划去初始化类变量和其他资源
      • 初始化顺序,由语句在源文件中出现的顺序决定。
        • 静态语句块只能访问到定义在它之前的类变量,定义在其之后的变量只能赋值,例如
            public class Test{
            	static{
                	i = 0;	// 可以
                    System.out.println(i); // 不被允许
                }
            }
          
        • 父类的 <clinit>() 方法先执行,意味着父类中的静态语句块的执行要优于子类。
      • 接口中不可以有静态语句块,但仍然有类变量初始化的赋值操作。
        • 与类不同的是,接口的 <clinit>() 不会先去执行父类的初始化 <clinit>(),只有使用到父接口的变量时。
      • 虚拟机会保证一个类的 <clinit>() 方法在多线程环境下被正确的加锁和同步
        • 如果多个线程同时初始化一个类,只会有一个线程执行这个类的 <clinit>() 方法,其他线程阻塞。

7.4 类与类加载器

7.4.1 如何判断类是否相等?

  • 同一个类加载器加载的才认为相等
    • 相等的情况下,Class 对象的 equals() ,isAssignableFron() 方法、isInstance()返回的结果才会是 true,也包括使用 instanceof 关键字做对象所属的关系判定结果为 true。

7.4.2 类加载器种类

  • 启动类加载器(Bootstrap ClassLoader) 此加载器负责加载<JRE_HOME>、lib 目录中的和被 -Xbootclasspath 参数指定的路径中的类。
    • 用户在编写自定义类加载器时,如果需要把加载请求委派给启动加载器,直接使用 null 代替即可???
  • 扩展类加载器(Extension ClassLoader) 加载器由 ExtClassLoader(sun.misc.Launcher$ExtClassLoader) 实现的。负责将 <JAVA_HOME>/lib/ext 目录下的和被 java.ext.dir 系统变量所指定的路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器,这个类加载器是由 AppClassLoader 实现,负责加载 ClassPath 上所指定的类库,开发者可以直接使用这个类加载器。如果程序中没有自定义类加载器,一般情况下这个就是程序中默认的类加载器。
  • 自定义类加载器
    • 需要继承 java.lang.ClassLoader
    • loadClass() 实现了双亲委派模型,自定义的类加载器如果想遵循双亲委派规则,一般不会去重写这个方法。
        protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
        {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            // 首先,检查 name 这个类是否已经被加载过
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }
      
                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);
      
                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
        }
      
    • 需要重写 findClass() 方法,默认抛出 ClassNotFoundException

12 章 Java 内存模型和线程

12.3 Java 内存模型

  • 基本模式
    • java 线程 <=> 工作内存 <=> 主内存
  • 原因
    • 为了屏蔽不同平台下的物理内存,让各种平台下都能达到一致的内存访问效果。

12.3.2 内存间交互操作

  • lock
  • unlock
  • read
  • load
  • use
  • assign
  • store
  • write

12.3.3 volatile 型变量

  1. 禁止指令重排
  2. 保证此变量对所有线程的可见性,即一个线程修改了变量值之后就会立即被其他线程得知。
    • 更新变量值的操作是原子操作。
    • 只能保证更新变量值之后,线程再次读取改值,该值是被修改之后的值。

12.3.5 内存模型需要遵循的原则

  • 原子性,提供底层的原子操作,还提供了 synchronized 关键字用来实现同步
  • 可见性,volatile 实现了变量修改后的立即可见性
  • 有序性,在本线程中观察,所有的操作都是有序的。线程内表现为串行的语义
    • 提前定义了先行发生原则(一组偏序关系)

12.4 Java 与 线程

12.4.1 线程的实现

  • 线程的出现可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件 I/O),又可以独立调度(线程时 CPU 调度的单位)。

有三种实现方式

1. 使用内核线程实现
  • 概念:
    • 内核线程(KLT),直接由操作系统内核支持的线程,KLT 由内核完成切换
    • 操纵调度器(Scheduler),对 KLT 进行调度,将其分布到各个 CPU 上
    • 多线程内核,支持多线程的内核就叫做
    • 轻量级进程(LWP),每一个进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。
    • 一对一的此线程模型,一个 LWP 对应一个 KLT
  • 优点:
    • 每个 LWP 都是一个独立的调度单元,即使有一个 LWP 阻塞了,也不会影响整个进程继续工作
  • 缺点:
    • 因为基于内核线程实现,各种线程操作都需要进行系统调用,需要在用用户态和内核态切换,代价较高。
    • 因为内核资源有限,所以 KTL 有限,因此 LWP 也是有限的。
2. 使用用户线程实现

线程完全在用户态实现,线程的操作完全在用户态完成,不需要内核的帮助。进程:用户线程为 1:N。

  • 优点:
    • 不需要系统内核支援,线程操作消耗小
  • 缺点:
    • 阻塞实现比较困难,LWP 阻塞只要内核线程 KLT 阻塞即可。
3. 使用用户线程加轻量级进程混合实现

用户线程与轻量级线程,N:M。

4. Java 线程的实现

Windows 和 Linux 都是一对一的线程模型。

12.4.2 进程调度

  • 协同式调度,一个线程执行完成通知另一个线程切换。
    • 实现简单
    • 没有进程同步问题
    • 线程执行时间不可控,如果一个线程有问题,不通知系统,那么会导致整个系统崩溃
  • 抢占式的多线程系统

12.4.3 状态转换

  • 新建(New),创建后尚未启动的线程处于这种状态
  • 运行(Runnable),包含了操作系统状态中的 Running 和 Ready,正处在此状态的线程可能正在执行,有可能正在等待 CPU 为它分配时间
  • 无限期等待(Waiting),这种状态的线程不会被分配 CPU 执行时间,需要等待被其他线程显式唤醒。
  • 限期等待(Time Waiting),处于这种状态的线程不会被分配 CPU 执行时间,无需等待被其他线程唤醒,到时间自然会醒。
  • 阻塞(Blocked),线程被阻塞了,”阻塞态” 和 “等待态” 的区别是等待着获得一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而”等待状态”则是在等待一段时间,或者是唤醒动作的发生。当线程进入同步区的时候,将会发生这种状态。
  • 结束(Terminated),已终止线程的线程状态,线程已经结束执行。

13 线程安全和锁优化

13.2 线程安全

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

13.2.1 Java 语言中的线程安全

  1. 不可变,不可变对象天生线程安全
  2. 绝对线程安全,符合定义
  3. 相对线程安全,对这个对象单独操作是线程安全的,但是对一些特定顺序的调用,可能需要在调用单使用额外的同步手段来保证调用的正确性。举例,Vector
  4. 线程兼容,对象本身不是线程安全的,但是可以在调用端正确地使用同步手段来保证并发环境中可以安全使用。
  5. 线程对立,无论采用什么手段都无法保证线程安全。

13.2.2 线程安全的实现方法

  1. 互斥同步
  2. 非阻塞同步
    • CAS,比较交换
      • 三个值,V(内存值),A(旧的预期值),B(新值)
      • 过程,当 V 值符合 A 值时,用新值 B 来更新 V 值,否则不更新。无论是否更新了 V 值都会返回 V 旧值。
      • AtomicInteger 的 getAndAddInt 方法解析:拿到内存位置的最新值 v,使用CAS尝试修将内存位置的值修改为目标值 v + delta ,如果修改失败,则获取该内存位置的新值 v,然后继续尝试,直至修改成功。参考
  3. 无同步方案
    • 可重入代码,不保存状态
    • 线程本地存储,线程封闭

13.3 锁优化

13.3.1 自旋锁和自适应自旋锁

  • 自旋锁,如果两个或者两个以上线程请求同一个锁,让后面请求锁的线程”等一下”,期望前面的线程能够在等一下的这个过程中释放锁。而不是直接挂起线程。
  • 自适应自旋锁,自旋多长时间不再固定,由前一次在同一个锁上自选时间及锁的拥有者的状态来决定。

13.3.2 锁消除

消除无用的锁,比如只有在线程内部拥有的状态,可以直接消除锁。

13.3.3 锁粗化

将一个范围内连续的几个细粒度锁给粗化成一个锁

13.3.4 轻量级锁

  1. 理论依据:对于绝大部分的锁,在整个同步周期内都是不存在竞争的。
  2. CAS 操作将对象的 Mark Word 更新为指向 Lock Record 的指针。
    • 获得内存中对象的 Mark Word 的值
    • 用 Mark Word 的值和现在 Mark Word 的值比较
    • 如果相同,那么用 Lock Record 替换 MarkWord 的值吗,如果不同,失败
    • ?为何会失败?
      • Mark Word 的值和现在的 Mark Word 的值不同了。在自己更改的时候,已经有其他线程改了。
        • 膨胀为重量级锁,将状态值改成 “10”
      • Mark Word 的内存处已经不是现在的 Mark Word 的值了,早就已经变成指向当前线程栈帧的指针了,说明已经拥有了这个锁。
  3. 解锁过程
    • 明显隐含的含义是,当前线程缓存了 Mark Word 的旧值( CAS 的基本过程)
    • 用线程中复制过来的 Mark Word 的值,用 CAS 操作,将对象的 Mark Word 替换过来
      • 成功,同步过程完成
      • 失败,说明 CAS 过程中,已经有其他线程修改了 Mark Word 的标志位为 10,即有其他线程来竞争,那么就在释放锁的同时唤醒其他被挂起的线程。

13.3.5 偏向锁

  1. 偏向第一个占有锁的线程
  2. 当发现有线程抢占时,膨胀为轻量级锁