Skip to content

JVM

Posted on:September 22, 2020 at 13:30:39 GMT+8

JVM 组成

  1. 类加载器
  2. 运行时数据区
  3. 执行引擎
  4. 本地库接口

Java 内存区域

Java 虚拟机运行时将内存分为 5 个区域:

  1. 程序计数器

    线程私有。通过这个计数器的值来选取下一个执行的字节码指令。不会 OutOfMemoryError。

  2. Java 虚拟机栈

    线程私有。每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口信息等。每一个方法从调用到执行完成的过程,就对应一个栈帧从入栈到出栈的过程。如果调用栈的深度超过了所允许的最大深度,会抛出 StackOverflowError。如果虚拟栈扩展时无法申请到足够的内存,会抛出 OutOfMemoryError。

  3. 本地方法栈

    本地方法栈为 Native 方法服务。StackOverflowError OutOfMemoryError

  4. Java 堆

    所有线程共享,用于存储对象实例,也是垃圾收集的重点对象。

    可以再细分为新生代和老年代,再细可以分成 Eden 空间和 From Survivor 空间和 To Survivor 空间。OutOfMemoryError

  5. 方法区

    所有线程共享。存放加载的类信息、常量、静态变量、JIT 编译器编译后的代码等。OutOfMemoryError

垃圾收集与内存分配

垃圾判定算法

  1. 引用计数算法

    给对象添加一个引用计数器。每当有一个地方引用到了就将计数器值加 1,当引用失效时就将计数器值减 1。如果对象的计数器值为 0,就是不可能再被使用的。但很难解决对象间互相引用的问题。

  2. 可达性分析算法

    从 GC Roots 出发,向下搜索,当一个对象到 GC Roots 没有引用链相连时,则判定为可回收。

可作为 GC Roots 的对象:

  1. Java 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中 类 静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法区中 JNI(native 方法)引用的对象

引用的类型

Java 1.2 后对引用的概念进行了扩充。

  1. 强引用

    new 出来的对象。

  2. 软引用 SoftReference

    有用但非必需的对象。将要内存溢出异常之前会对这些对象列入回收对象进行回收,若这次收集后还没有足够的内存,才会抛出内存异常。

  3. 弱引用 WeakReference

    只能生存到下次回收之前

  4. 虚引用 PhantomReference

    唯一目的能在这个对象被收集器回收时收到一个系统通知。

垃圾收集算法

  1. 标记-清除算法

    分为标记和清理两个阶段。首先标记出所有需要回收的对象,在标记完成后统一清理回收。

    效率问题,标记和清除的效率都不高。空间问题,会产生不连续的内存碎片。

  2. 标记-复制算法

    将内存划分为几部分。同时只使用其中的部分,当一部分的内存用完后,将仍然存活的对象复制到还没使用的部分。

  3. 标记-整理算法

    先标记,然后将存活的对象都向一端移动,然后直接清理掉端边界外的内存。

分代收集:根据对象存活周期的不同,将内存分为几块。新生代使用复制算法,老年代使用标记-清除或标记-整理算法。

垃圾收集器

  1. Serial 收集器

    单线程,垃圾收集时必须暂停其它所有的工作线程。

    简单而高效。

  2. PreNew 收集器

    Serial 收集器的多线程版本。

  3. Parallel Scavenge

    达到一个可控制的吞吐量—— CPU 用于用户代码的时间和 CPU 总消耗时间的比值。

  4. Serial Old

    Serial 收集器的老年代版本。

  5. Parallel Old

    Parallel Scavenge 收集器的老年代版本。

  6. CMS Concurrent Mark Sweep

    以获取最短回收停顿时间为目标的收集器。

    运行过程分为 4 个步骤:

    初始标记

    并发标记

    重新标记

    并发清理

    缺点:

    对 CPU 资源非常敏感。

    无法处理浮动垃圾。

    CMS 基于标记-清除,可能产生内存碎片,为大对象的内存分配带来麻烦。

  7. G1

    运行过程:

    初始标记

    并发标记

    最终标记

    筛选回收

    停顿时间模型的收集器,支持指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒。

    G1 收集器使用 Mixed GC 模式——垃圾收集的衡量标准不再是 TA 属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大。实现这个目标的关键是 G1 开创的基于 Region 的堆内存布局。

    G1 为每个 Region 设计了两个名为 TAMS(Top at Mark Set) 的指针,把 Region 中的一部分内存划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置上。

    G1 从整体看来是基于“标记-整理”算法实现的收集器,但从局部(两个 Region 之间)上看又是基于“标记-复制”算法实现,都不会产生内存碎片。

    缺点:内存占用和额外执行负载都要比 CMS 高。

Class 类文件

类加载

类加载的时机

有且只有 6 种情况:

  1. 遇到 new、getstatic、putstatic、invokestatic 这 4 条字节码。使用 new 关键字实例化对象、读取一个类的静态字段、设置一个类的静态字段、调用一个类的静态方法。
  2. 使用java.util.reflect包中的方法对类进行反射调用时,且类没有进行过初始化。
  3. 当初始化一个类时,如果父类没有进行过初始化,则触发父类的初始化。
  4. 虚拟机启动时,初始化主类。
  5. 使用 JDK 7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic REF_putStatic REF_invokeStatic REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有初始化,则需要先触发其初始化。
  6. 当一个接口中定义了 JDK 8 中新加入的 default 方法时,如果有这个接口的实现类发生了初始化,则接口要在其之前被初始化。

这 6 中对一个类型进行主动引用的情况。

类加载的过程

  1. 加载
  2. 验证
  3. 准备
  4. 解析
  5. 初始化

类加载器

双亲委派模型

类加载器可以分为 3 种:

双亲委派模型:类加载器之间具有层次关系,称为类加载器的双亲委派模型。

双亲委派模型要求除了顶层的启动类加载之外,都要有自己的父类加载器。类加载器之间的父子关系一般由组合实现。双亲委派模型的工作流程,当一个类加载收到一个类加载的请求时,会先将请求委派给父类加载器完成,会层层上传。只有父加载器反馈无法完成这个加载请求时(即无法在路径中搜索到所需的类),子加载器才会尝试自己加载。

双亲委派模型的优点:Java 类随着 TA 的类加载器一起具备了一种带有优先级的层次关系。例如 java.lang.Object 类在 rt.jar 中,是委派给顶层的启动类加载器完成的,因此 Object 类在各种类加载过程中都是同一个类。

实现原理:在 java.lang.ClassLoader#loadClass() 方法中。先检查类是否已经被加载过,如果加载过就直接返回已加载的类。否则调用父加载器的 loadClass() 方法,如果父加载器为 null,则使用启动加载器加载,如果还返回 null,才会调用本身的类加载器完成加载请求。

Java 语法糖

Java 内存模型与线程

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即如何将变量存储到内存,如何将变量从内存中取出。

Java 内存模型规定了所有变量都存储在主内存(可类比机器物理硬件的主内存)。每条线程还有自己的工作内存(可类比处理器高速缓存),工作内存保存了该线程用到的主内存变量拷贝副本。线程对变量的读取、赋值等发生在工作内存。要先在工作内存中赋值,再写回主内存。不同线程之间的变量值的访问传递也要通过主内存为中介完成。

实现细节,定义了 8 种原子操作:

lock

unlock

read

load

use

assign

store

write

把一个变量从主内存复制到工作内存,需顺序执行 read load。

八种原子操作必须满足规则:

不允许 read loal、store write 单独出现,即不允许一个变量从主内存读取后不被工作内存接受,不允许一个变量从工作内存发起了写回而主内存不接受。

不允许一个线程丢弃 TA 最近的 assign 操作,即变量在工作内存中改变了必须要同步回主内存。

不允许一个线程在没有发生 assign 操作时,将数据从工作内存同步回主内存。

一个新的变量必须在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化的(load 或 assign)的变量,即对一个变量实施 use 、store 操作之前,必要要先执行 assign 和 load 操作。

一个变量必须在同一个时刻只能有一个线程对其进行 lock 操作,但 lock 操作可以被一个线程执行多次,多次执行 lock 后,必须执行相同次数的 unlock 才能解锁。

如果对一个变量执行 lock 操作,会清空工作内存中此变量的值,在执行引擎使用这个值前,必须重新执行 load 或 assign 操作以初始化变量的值。

如果一个变量没有执行过 lock 操作,就不能对其 unlock。也不能 unlock 一个被其它线程锁定的变量。

对一个变量 unlock 前,必须把此变量同步回主内存(执行 store、write)操作。

volatile

第一:保证了变量对所有线程的可见性。即当一个线程修改了这个变量的值,其它线程是立即可以得知的。

第二:禁止指令重排序。

有 volatile 修饰的变量,赋值后多了一个 lock addl $0x0, (%esp) 操作,操作相当于一个内存屏障,重排序时不能把内存屏障后的指令排序到内存屏障之前。

先行发生原则 happens-before

与上面规则等效的判定原则:

程序次序规则

管程锁定原则

volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。

线程启动规则

线程终止原则

线程中断原则

对象终结原则

传递性

Java 线程

线程安全与锁优化

线程安全的实现方法

互斥同步(阻塞同步)

使用 synchronized 关键字

使用 ReentrantLock

等待可中断

公平锁

锁可以绑定多个条件

非阻塞同步

基于冲突检测的乐观并发策略,需要硬件支持操作和冲突检测是原子的。

不可变

锁优化

自旋锁 自适应自旋锁

互斥同步对性能的最大影响是阻塞的实现,挂起和恢复线程都要陷入内核态。如果物理机器有多个 CPU 或者多个 CPU 核心,可以允许两个或多个线程同时并发运行,那么可以让后面请求锁的线程自旋等待一会儿,但不放弃处理器的执行时间,看持有锁的线程是否会很快释放锁。

自适应:根据具体情况,由上一次在同一个锁上的自旋时间和持有锁的线程的状态决定自旋等待的时间。

锁消除

锁粗化

轻量级锁

偏向锁

参考

Garbage Collection Roots

深入理解 Java 虚拟机:JVM 高级特性与最佳实践 / 周志明著 .——2 版.——北京:机械工业出版社,2013.6

经典面试题|讲一讲JVM的组成