本篇是对 JVM 基本概念的回顾,主要参考了《深入理解 Java 虚拟机》。
内存模型
- 线程隔离的数据区:
- 程序计数器,指示当前线程所执行的字节码行号
- 虚拟机栈,生命周期与线程相同,存储局部变量表(编译时可预知的基本数据类型,对象引用,returnAddress)。描述的是 Java 方法执行的模型:每个方法执行是都会创建一个栈帧用来存储局部变量表,方法出口,操作数栈,动态链接等,方法开始时入栈,结束时出栈
- 本地方法栈,与虚拟机栈类似,只不过执行的是 Native 方法
- 线程共享的数据区:
- 堆,存放对象实例
- 方法区,存储虚拟机加载的类信息,常量,静态变量,及时编译后的代码等。包含运行时常量池
- 不属于 JVM 管理的内存区域
- 直接内存:在 JDK1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
JDK8 中 JVM 发生的变化
元数据区取代了永久代。元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元数据空间并不在虚拟机中,而是使用本地内存。
原因:
- 官方文档:移除永久代是为融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,不需要配置永久代
- PermGen 很难调整,PermGen 中类的元数据信息在每次 FullGC 的时候可能被收集,但成绩很难令人满意。
内存分配与回收
分配:
- 指针碰撞,规整。Serial、ParNew 等带压缩整理过程的收集器时采用
- 空闲列表,不规整,已使用和未使用交错。CMS 这种基于清除算法的收集器是理论上(实际上有一块较大的缓存,缓存中用指针碰撞)采用
- 分配在并发的情况下也不是线程安全的,解决有两种方式:
- 同步,实际上采用 CAS 配上重试方式保证原子性
- 把内存分配的动作按照线程划分在不同的空间,即使用本地线程分配缓冲(TLAB),只有当缓冲用完后才需要同步分配新的缓冲空间
对象布局:
对象头,实体数据,对齐填充
回收:
- 回收什么样的对象:对象不再能够被访问到的时候。判断方法:
- 引用计数法
- 可达性算法,当一个对象到 GC Roots 之间没有任何链路时为不可达。主流 JVM 并不会挨个检查,而是会维护引用和内存的位置 map。
- Gc Roots 包括:
- 虚拟机栈中引用的对象
- 本地方法栈中引用的变量
- 方法区中静态属性引用的变量
- 方法区中常量引用的变量
- 回收在哪里进行:
- 程序计数器,虚拟机栈,本地方法栈 3 个区域的随线程而生灭;每个栈帧中分配多少内存基本在类结构确定后就确定了
- Java 堆
- 方法区(在方法区主要回收两部分:废弃常量,无用的类)
- 何时进行回收:
- 在新生代的 Eden 区满了,会触发新生代 GC(MinorGC)
- 经过多次触发新生代 GC 存活下来的对象就会升级到老年代,升级到老年代的对象所需的内存大于老年代剩余的内存,则会触发老年代 GC(FullGC)
- 当程序调用 System.gc()时也会触发 Full GC
- 方法:
- 标记清除,会产生较多碎片,执行效率不稳定
- 复制算法,需要额外空间
- 标记整理,标记后移动存活对象,直接清除另端边界对象
- 分代收集,老年代(1,3),新生代(2)
- 安全点(safepoint),虚拟机在安全点更新引用内存 map(HotSpot 的 OopMap)。抢先式终斷(先停,发现不在安全点,运行到安全点再停,几乎不用),主动式中断(轮询标志,发现标志为争自行挂起)
- 安全区域(safe region),安全点扩展。若在安全区时发生 GC 则不做任何操作,直到出时检测是否完成根节点枚举(或整个 GC)若完成则继续,没有则等待信号出。
OOM 分析原因
- 堆内存不足是最常见的 OOM 原因之一。具体原因很多:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定 JVM 堆大小或者指定数值偏小;或者出现 JVM 处理引用不及时,导致堆积起来,内存无法释放等。
- Java 虚拟机栈和本地方法栈。如果我们写一段程序不断的进行递归调用,而且没有退出条件,就会导致不断地进行压栈。类似这种情况,JVM 实际会抛出
StackOverFlowError
;当然,如果 JVM 试图去扩展栈空间的的时候失败,则会抛出 OutOfMemoryError
- 对于老版本的 Oracle JDK,因为永久代的大小是有限的,并且 JVM 对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似 Intern 字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息,会标记出来和永久代相关:
java.lang.OutOfMemoryError: PermGen space
- 随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 OOM 有所改观,出现 OOM,异常信息则变成了:
java.lang.OutOfMemoryError: Metaspace
- 直接内存不足,也会导致 OOM。本机内存不足,或
-XX:MaxDirectMemorySize
设置的直接内存大小不足。
新生代分为 Eden 和 Survivor
新对象分配发生在 Eden 区,若 Eden 区没有足够的位置,会触发 YGC。YGC 存活下来的对象会进入 Survivor 区。
Survivor 区的意义:减少被送到老年代的对象,进而减少 Full GC 的发生,Survivor 的预筛选保证,只有经历 16 次 Minor GC 还能在新生代中存活的对象,才会被送到老年代
两个 Survivor 区的意义:设置两个 Survivor 区最大的好处就是解决了碎片化,刚刚新建的对象在 Eden 中,经历一次 Minor GC,Eden 中的存活对象就会被移动到第一块 survivor space S0,Eden 被清空;等 Eden 区再满了,就再触发一次 Minor GC,Eden 和 S0 中的存活对象又会被复制送入第二块 survivor space S1(这个过程非常重要,因为这种复制算法保证了 S1 中来自 S0 和 Eden 两部分的存活对象占用连续的内存空间,避免了碎片化的发生)
对象怎么进入老年代
- 迭代年龄判断,参数
- XX:MaxTenuringThreshold
- 大对象直接进入老年代,参数
- XX:PretenureSizeThreshold
- YoungGC 之后需要移区的对象放不下,在进行移区的时候,可能需要移区的对象大于所移区的空间大小,那么这些对象会被直接放入老年代
- 对象动态年龄判断。此策略发生在 Survivor 区,当 Survivor 区中的一批对象的总大小大于 Survivor 区空间大小的一半,在这个区域中,对象年龄大于这批对象的最大年龄的所有对象会被移入老年代。
垃圾回收器
- Serial GC
- 单线程,复制算法,新生代
- 工作时有stop the world,暂停所有用户线程,造成卡顿
- 它是最古老的垃圾收集器,“Serial”体现在其收集工作是单线程的,并且在进行垃圾收集过程中,会进入臭名昭著的“Stop-The-World”状态。当然,其单线程设计也意味着精简的 GC 实现,无需维护复杂的数据结构,初始化也简单,所以一直是 Client 模式下 JVM 的默认选项。
- Serial Old,它采用了标记 - 整理(Mark-Compact)算法,区别于新生代的复制算法。从年代的角度,通常将其老年代实现单独称作 Serial Old,它采用了标记 - 整理(Mark-Compact)算法,区别于新生代的复制算法。
-XX:+UseSerialGC
- ParNew GC,很明显是个新生代 GC 实现,它实际是 Serial GC 的多线程版本,单 CPU 环境不会比 Serial 性能好,最常见的应用场景是配合老年代的 CMS GC 工作。
-XX:+UseConcMarkSweepGC -XX:+UseParNewGC
- CMS(Concurrent Mark Sweep) GC,基于标记 - 清除(Mark-Sweep)算法,设计目标是尽量减少停顿时间,这一点对于 Web 等反应时间敏感的应用非常重要,一直到今天,仍然有很多系统使用 CMS GC。但是,CMS 采用的标记 - 清除算法,存在着内存碎片化问题,所以难以避免在长时间运行等情况下发生 full GC,导致恶劣的停顿。另外,既然强调了并发(Concurrent),CMS 会占用更多 CPU 资源,并和用户线程争抢。
- Parallel GC,在早期 JDK 8 等版本中,它是 server 模式 JVM 的默认 GC 选择,也被称作是吞吐量(赋值器与收集器效率的总和,运行用户代码的时间/(运行用户代码的时间+垃圾回收的时间))优先的 GC。它的算法和 Serial GC 比较相似,尽管实现要复杂的多,其特点是新生代和老年代 GC 都是并行进行的,在常见的服务器环境中更加高效。
-XX:+UseParallelGC
- G1 GC 这是一种兼顾吞吐量和停顿时间的 GC 实现,是 Oracle JDK 9 以后的默认 GC 选项。G1 可以直观的设定停顿时间的目标,相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多。G1 GC 仍然存在着年代的概念,但是其内存结构并不是简单的条带式划分,而是类似棋盘的一个个 region。Region 之间是复制算法,但整体上实际可看作是标记 - 整理(Mark-Compact)算法,可以有效地避免内存碎片,尤其是当 Java 堆非常大的时候,G1 的优势更加明显。
回收器 |
收集对象和算法 |
收集器类型 |
Serial |
新生代,复制算法 |
单线程 |
ParNew |
新生代,复制算法 |
并行的多线程收集器 |
Parallel Scavenge |
新生代,复制算法 |
并行的多线程收集器 |
Serial Old |
老年代,标记整理算法 |
并行的多线程收集器 |
Parallel Old |
老年代,标记整理算法 |
并行的多线程收集器 |
CMS |
老年代,标记清除算法 |
并行的多线程收集器 |
G1 |
跨新生代和老年代,复制算法 + 标记整理算法 |
并行和并发收集器 |
排查
命令
- 内存使用
jstat -gcutil pid 10000 // 查看gc情况,每10秒打印一次
jmap -histo:live pid | head -n 100 // 查看堆中前100的对象
jmap -dump:format=b,file=/opt/jvm-06-29 1 # 导出进程1的jvm状态
- 线程情况
- 参数
jps // 运行java的进程
top // 进程的 CPU 使用率、内存使用率以及系统负载等
top -Hp pid // 具体线程的情况
jinfo pid // jvm参数查看
cpu 高
- 死循环
- 频繁 fullgc
内存高
- 内存泄漏
- 递归
类加载
-
类的生命周期:加载 –> 验证 –> 准备 –> 解析 –> 初始化 –> 使用 –> 卸载
-
时机(主动引用时)
- new, getstatic, putstatic, invokestatic
- 反射调用
- 一个类被加载时,发现父类未加载,先加载父类
- 启动时的主类
- JDK7,若 java.lang.invoke.MethodHandler 实例最后解析的结果是 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄
-
过程:过程包括 加载,验证,准备,解析,初始化
- 加载:
- 步骤:
- 获取二进制字节流
- 将字节流中定义静态数据结构加载到方法区运行时
- 生成 java.lang.Class 对象(堆),作为方法区的访问入口
- 类加载器,每个类加载器有独立的类名称空间(不同类加载器加载的类必定不相同)。类加载器通常使用,双亲委派模型
- 验证:
- 文件格式验证(魔数,版本号,常量类型。。。)
- 元数据验证(是否有父类,是否继承了不能继承的类。。。),保证不存在不符合 Java 规范的元数据
- 字节码验证,语意是否合法
- 符号引用验证(解析阶段发生)
- 准备:初始化类的变量到方法区,static 变量初始化为 0,final 变量初始化为设置值
- 解析:符号引用替换为直接引用,可以认为是一些静态绑定的会被解析,动态绑定则只会在运行是进行解析;静态绑定包括一些 final 方法(不可以重写),static 方法(只会属于当前类),构造器(不会被重写)
- 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可
- 直接引用:可以是直接指向目标的指针,相对偏移量,一个能间接定位到目标的句柄
- 初始化:执行类构造器
<clinit>()
方法的过程
虚拟机执行
- 栈帧(stack frame):虚拟机每执行一个方法就是一次栈帧从入栈到出栈的过程,帧栈包含了:局部变量表,操作数栈,动态连接,返回地址等(编译时已确定)。两帧栈作为虚拟机元素是完全独立的,但是大多数虚拟机会做一些优化,将上一帧栈的局部变量表与下一个帧栈的操作数栈部分重叠在一起,以共享一些数据,减少数据的复制传递。
- 局部变量表:以变量槽(variable solt)为最小单位,一般 32 位。局部变量不会赋初始值
- 操作数栈:先入后出。在方法执行前操作栈是空的,运行时字节码指令往数据栈中写入和提取内容(出栈/入栈)
- 动态连接:符号引用在类加载过程的解析阶段转化为直接引用是静态连接,运行时则是动态引用
- 返回地址:正常推出时使用保存的计数器值,异常退出则查询异常处理器表。
- 方法调用:确定被调用方法的版本。Class 文件编译期间不包含传统的编译过程,一切方法调用在 Class 文件都是符号引用
- 解析:类加载阶段的解析会确定内部方法和类方法的直接引用。非虚方法(内部方法,类方法,final 方法)的调用过程叫解析。解析调用一定是静态的,在类转载的时候就会转为直接引用,不会延迟到运行时
- 分派:虚拟机通过虚方法表(稳定优化)提升效率,也使用内联缓存和类型继承关系分析(激进优化)提升效率
- 静态分派:依赖静态类型(外观类型)的分派,也就是重载
- 动态分派:根据实际类型分派方法版本,即重写。用 invokevirtual 指令实现。
- 单分派和多分派:方法的接收者和参数统称为方法的总量。java 是静态多分派,动态单分派,即静态分派关系调用者和参数类型,动态只关心调用者
并发
内存模型:
定义在虚拟机中变量的访问规则。每个线程有自己的工作区域内存,在工作区域内存中缓存用到的主内存变量的缓存,线程对变量的操作(读取,写入)都在工作内存中进行,线程间变量值的传递均需要通过主内存。内存模型围绕如何处理并发过程中的一致性,可见性,有序性来建立的
内存间的交互:
Java 内存模型定义了 8 种原子性的内存操作来完成工作内存与主内存之间的具体交互
- lock:主内存,把变量标识为一条线程独占
- unlock:主内存,把处于锁定状态的变量释放出来,释放后才可以被其他线程锁定
- read:主内存,把变量值从主内存传输到工作线程中
- load:工作内存,把 read 操作从主内存中得到的值放到工作内存变量副本中
- use:工作内存,把一个工作内存中的变量值传递给执行引擎
- assign:工作内存,把从执行引擎接收到的值放到工作内存变量副本中
- store:工作内存,把工作内存的变量的值传给主内存
- write:主内存,store 操作得到的值放入主内存变量中
一些规则:
- read/load,store/write 不能单独出现
- 不允许丢弃最近一次 assign 操作,即工作区变量改变必须同步回主内存
- 不允许工作区变量拷贝无原因的同步,即没有调用 assign
- 新变量只能在内存中诞生
- 一个变量只能被一个线程 lock,这个 lock 是可重入的
- lock 后会清空工作区该变量的值,需重新执行 load 或 assign 操作
- 没有 lock 不能 unlock,线程不能 unlock 别的线程的 lock
- unlock 前必须把变量同步回主内存
先行发生原则:JMM 中定义两项操作的偏序关系,如果操作 A 和操作 B 满足,比如操作 A 先行发生于操作 B,那么操作 B 一定能看到操作 A 的影响。JVM 定义了一些天然的先序发送原则,重排不会改变其运行顺序:单线程按代码顺序;锁释放发送在下一次获取锁之前;volatile 写入发生在下一次读之前;Thread start 发生于其他线程操作之前;Thread 里所有操作发生在 Thread 中止之前;Thread interrupt()
调用发生在线程中断处理之前;对象初始化先于finalize()
;传递性
volatile:
- 语意:
- 保证可见性。在变量改变需要依赖当前值,或者需要与其他变量共同参与不变性约束时需要额外同步来保证原子性
- 禁止指令重排优化。通过在编译时加入一个 lock 前缀指令,相当于内存屏障
- 更多
synchronized 原理
- 可重入、互斥锁
- 三种使用方式:
- 1)修饰方法,锁定当前对象
- 2)修饰静态方法,锁定的当前类的 Class 实例
- 3)修饰代码块,锁定指定的对象
- synchronized 用的锁是存在 Java 对象头里的。JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步。代码块同步是使用 monitorenter 和 monitorexit 指令实现的,monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处。任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态。根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加 1;相应地,在执行 monitorexit 指令时会将锁计数器减 1,当计数器被减到 0 时,锁就释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
java 线程调度:
主要有两种线程调度模式:协同(线程运行结束通知下一线程开始运行)和抢占(系统分配线程运行时间)。java 使用抢占式,并且有不靠谱的优先级设定实现线程调度。
状态转化:
new, runable, waiting, timed waiting, blocked, terminated
线程安全
当多个线程访问一个对象的时候,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。—>《Java Concurrency In Practice》
- 安全等级:不可变,绝对安全,相对安全(
HashTable
, Vector
),线程兼容(HashMap
, ArrayList
),线程对立(尽量避免)
- 实现方法:互斥同步(各种锁),非阻塞同步(CAS),天生安全(可重入代码,线程本地存储)
- 锁消除:代码要求同步,但被检测无竞争的锁进行消除。
- 锁粗化:粗化对于一个锁的请求和释放
- 轻量级锁:轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要 CPU 从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放
- 自旋锁:锁被其他线程持有,当前等待一定时间次数的忙循环,不放弃运行时间
- 自适应自旋锁:旋转时间不固定,由上一次同一个锁的自旋时间和当前锁状态决定
- 偏向锁:大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。偏向锁不主动释放,在产生竞争时判断是否退出同步块,若退出则释放,未退出则升级为轻量级锁
锁 |
优点 |
缺点 |
适用场景 |
偏向锁 |
加锁解锁无需额外消耗,性能与非同步方法近似 |
当出现竞争时带来额外锁撤销消耗 |
竞争较少的场景 |
轻量级锁 |
竞争线程不会阻塞,提高响应速度 |
空转消耗 CPU |
追求响应时间,竞争锁等待时间较短 |
重量级锁 |
竞争线程阻塞,不会空转消耗 CPU |
线程阻塞,上下文切换消耗 |
追求吞吐量,竞争等待时间较长 |
对象的一生
现在我们把所有的知识点串联起来,理解以下代码在 jvm 中的运行过程:
- 加载 Person.class 文件进内存
- 在栈内存为 p 开辟空间
- 在堆内存为 Person 对象开辟空间
- 对 Person 对象的成员变量进行默认初始化
- 对 Person 对象的成员变量进行显示初始化
- 通过构造方法对 Person 对象的成员变量赋值
- Person 对象初始化完毕,把对象地址赋值给 p 变量
public class MyApp{
public static void main(String[] args) {
Person p = new Person("x", 18);
p.say();
}
public static class Person{
private String name;
private Integer age;
// getter setter
public void say(){
}
}
}

comments powered by