深入理解JVM:内存结构垃圾回收类加载内存模型
Posted Henrik-Yao
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入理解JVM:内存结构垃圾回收类加载内存模型相关的知识,希望对你有一定的参考价值。
文章目录
跨平台运行与自动垃圾回收是 java 语言的两大特性,依靠 Java Virtual Machine(JVM),也就是 java 虚拟机实现
JVM,JRE,JDK的区别:
内存结构
java 虚拟机的内存结构分为方法区、堆、虚拟机栈、程序计数器、本地方法栈,其中方法区和堆是线程共享的,虚拟机栈、程序计数器、本地方法区是线程私有的
结合一段 java 代码的执行理解内存划分
- 执行 javac 命令编译源代码为字节码
- 执行 java 命令
- 创建 JVM,调用类加载子系统加载 class,将类的信息存入方法区
- 创建 main 线程,使用的内存区域是 JVM 虚拟机栈,开始执行 main 方法代码
- 如果遇到了未见过的类,会继续触发类加载过程,同样会存入方法区
- 需要创建对象,会使用堆内存来存储对象
- 不再使用的对象,会由垃圾回收器在内存不足时回收其内存
- 调用方法时,方法内的局部变量、方法参数所使用的是 JVM 虚拟机栈中的栈帧内存
- 调用方法时,先要到方法区获得到该方法的字节码指令,由解释器将字节码指令解释为机器码执行
- 调用方法时,会将要执行的指令行号读到程序计数器,这样当发生了线程切换,恢复时就可以从中断的位置继续
- 对于非 java 实现的方法调用,使用内存称为本地方法栈(见说明)
- 对于热点方法调用,或者频繁的循环代码,由 JIT 即时编译器将这些代码编译成机器码缓存,提高执行性能
会发生内存溢出的区域
- 不会出现内存溢出的区域 – 程序计数器
- 出现 OutOfMemoryError 的情况
- 堆内存耗尽 – 对象越来越多,又一直在使用,不能被垃圾回收
- 方法区内存耗尽 – 加载的类越来越多,很多框架都会在运行期间动态产生新的类
- 虚拟机栈累积 – 每个线程最多会占用 1 M 内存,线程个数越来越多,而又长时间运行不销毁时
- 出现 StackOverflowError 的区域
- JVM 虚拟机栈,原因有方法递归调用未正确结束、反序列化 json 时循环引用
1. 本地方法栈(Native Method Stack)
一些带有 native 关键字的方法就是需要 JAVA 去调用本地的 C 或者 C++ 方法,因为 JAVA 有时候没法直接和操作系统底层交互,所以需要用到本地方法
2. 程序计数器(Program Counter Registe)
也叫 PC 寄存器,正在执行 java 方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址),如果还是 native 方法,则为空
3. 虚拟机栈(Java Virtual Machine Stacks)
每个线程有一个私有的栈,随着线程的创建而创建,栈里面存着栈帧,每个方法会创建一个栈帧,栈帧中存放了局部变量表(基本数据类型和对象引用)、操作数栈、方法出口等信息
4. 堆(Heap )
通过 new 关键字创建的对象都会被放在堆内存,线程共享,堆中对象都需要考虑线程安全的问题,有垃圾回收机制,初始化的对象,成员变量 (那种非 static 的变量),所有的对象实例和数组都要在堆上分配
5. 方法区(Method Area)
存储类信息,常量池(static 常量和 static 变量),编译后的代码(字节码)等数据
JDK7 以前,方法区的具体实现是永久代,在 JDK8,移除了永久代,用元空间取代,原来类的静态变量和 StringTable 都被转移到了堆区,只有 class 元数据才在元空间。元空间通过在本地内存区域开辟空间实现方法区
元空间并不在 JVM 中,而是使用本地内存,所以默认情况下,元空间的大小仅受本地方法限制
永久代被元空间取代的原因:
- 字符串存在永久代中,容易出现性能问题和内存溢出
- 类及方法的信息大小难以确定,因此对永久代的大小指定比较困难,太小容易出现永久代溢出,太大容易导致老年代溢出
- 永久代会给 GC 带来不必要的复杂度,并且回收效率低
常量池与运行时常量池
- 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
- 运行时常量池,常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
StringTable
- 常量池中的字符串仅是符号,第一次用到时才变为对象(类似懒惰行为 )
- 利用串池的机制,来避免重复创建字符串对象
- 字符串变量拼接的原理是 StringBuilder (1.8),拼接出来的字符串存在堆中
- 字符串常量拼接的原理是编译期优化,创建出的字符串对象放入串池中
- 可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池
- StringTable在内存紧张时,会发生垃圾回收
- StringTable调优:因为 StringTable 是由 HashTable 实现的,所以可以适当增加 HashTable 桶的个数,来减少字符串放入串池所需要的时间
垃圾回收(GC)
在 JVM 中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫面那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收
GC 的目的在于实现无用对象内存自动释放,减少内存碎片、加快分配速度
GC 要点:
- 回收区域是堆内存,不包括虚拟机栈
- 判断无用对象,使用可达性分析算法,三色标记法标记存活对象,回收未标记对象
- GC 具体的实现称为垃圾回收器
- GC 大都采用了分代回收思想
- 理论依据是大部分对象朝生夕灭,用完立刻就可以回收,另有少部分对象会长时间存活,每次很难回收
- 根据这两类对象的特性将回收区域分为新生代和老年代,新生代采用标记复制法、老年代一般采用标记整理法
- 根据 GC 的规模可以分成 Minor GC(清理新生代),Major GC(清理老年代),Full GC(整堆清理)
1. 如何判断对象可以回收
引用计数法
每一个对象设置一个引用计数器,每当有一个地方引用这个对象时,就将计数器加一,引用失效时,计数器就减一。当一个对象的引用计数器为零时,说明此对象没有被引用,也就是死对象,将会被垃圾回收
弊端:循环引用时,两个对象的计数都为1,导致两个对象都无法被释放
可达性分析算法
JVM中的垃圾回收器通过可达性分析来探索所有存活的对象,扫描堆中的对象,看能否沿着GC Root对象为起点的引用链找到该对象,如果找不到,则表示可以回收
GC Root包括:
- 虚拟机栈中引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中(即一般说的 native 方法)引用的对象
四种引用
- 强引用:只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收
- 软引用(SoftReference):仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用对象,可以配合引用队列来释放软引用自身
- 弱引用(WeakReference):仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象,可以配合引用队列来释放弱引用自身
- 虚引用(PhantomReference):必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存
四种引用举例
引用类型 | 例子 |
---|---|
强引用 | A a = new A(); |
软引用(SoftReference) | SoftReference a = new SoftReference(new A()); |
弱引用(WeakReference) | WeakReference a = new WeakReference(new A()); |
虚引用(PhantomReference) | PhantomReference a = new PhantomReference(new A(), referenceQueue); |
终结器引用(FinalReference) 无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize 方法,第二次 GC时才能回收被引用对象
finalize
- finalize是 Object 中的一个方法,如果子类重写它,垃圾回收时此方法会被调用,可以在其中进行资源释放和清理工作
- 将资源释放和清理放在 finalize 方法中非常不好,非常影响性能,严重时甚至会引起 OOM,从 Java9 开始就被标注为 @Deprecated,不建议被使用了
2. 垃圾回收算法
标记清除(Mark Sweep)
速度较快,会造成内存碎片
标记整理(Mark Compact)
速度慢,没有内存碎片
复制(Copy)
不会有内存碎片,需要占用双倍内存空间
3. 分代垃圾回收
相关 VM 参数
含义 | 参数 |
---|---|
堆初始大小 | -Xms |
堆最大大小 | -Xmx 或 -XX:MaxHeapSize=size |
新生代大小 | -Xmn 或 (-XX:NewSize=size + -XX:MaxNewSize=size ) |
幸存区比例(动态) | -XX:InitialSurvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy |
幸存区比例 | -XX:SurvivorRatio=ratio |
晋升阈值 | -XX:MaxTenuringThreshold=threshold |
晋升详情 | -XX:+PrintTenuringDistribution |
GC详情 | -XX:+PrintGCDetails -verbose:gc |
FullGC 前 MinorGC | -XX:+ScavengeBeforeFullGC |
4. 垃圾回收器
从分代角度划分收集器的种类:
- 新生代收集器(全部都是复制算法):Serial、ParNew、Parallel Scavenge
- 老年代收集器:CMS(标记-清除)、Serial(标记-整理)、Parallel Old(标记整理)
- 整堆收集器:G1(一个Region中式标记-清除,两个Region之间是复制)
(1)串行
配置参数:
-XX:+UseSerialGC = Serial + SerialOld
Serial 收集器
Serial收集器是最基本的、发展历史最悠久的收集器
特点:单线程、简单高效(与其他收集器的单线程相比),采用复制算法。对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程手机效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)
Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本
特点:同样是单线程收集器,采用标记-整理算法
(2)吞吐量优先
配置参数:
-XX:+UseParallelGC ~
-XX:+UseParallelOldGC
-XX:+UseAdaptiveSizePolicy
-XX:GCTimeRatio=ratio
-XX:MaxGCPauseMillis=ms
-XX:ParallelGCThreads=n
Parallel Scavenge收集器
Parallel Scavenge 收集器与吞吐量关系密切
特点:属于新生代收集器,采用并行复制算法,支持多线程,该收集器的目标是达到一个可控制的吞吐量
GC自适应调节策略:Parallel Scavenge 收集器可设置 -XX:+UseAdptiveSizePolicy 参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为 GC 的自适应调节策略
Parallel Scavenge 收集器使用两个参数控制吞吐量:
- XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间
- XX:GCRatio 直接设置吞吐量的大小
Parallel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本
特点:多线程,采用标记-整理算法
(3)响应时间优先(CMS)
配置参数:
-XX:+UseConcMarkSweepGC ~
-XX:+UseParNewGC ~ SerialOld
-XX:ParallelGCThreads=n ~
-XX:ConcGCThreads=threads
-XX:CMSInitiatingOccupancyFraction=percent
-XX:+CMSScavengeBeforeRemark
ParNew 收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程外其余行为均和Serial收集器一模一样(参数控制、收集算法、Stop The World、对象分配规则、回收策略等)
特点:多线程、ParNew 收集器默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多的环境中,可以使用 -XX:ParallelGCThreads 参数来限制垃圾收集的线程数。和 Serial收集器一样存在 Stop The World 问题
CMS 收集器
Concurrent Mark Sweep,一种以获取最短回收停顿时间为目标的老年代收集器
特点:基于标记-清除算法实现,并发收集、低停顿
缺点:对 CPU 资源敏感、标记-清除算法本身会造成空间碎片问题、无法处理浮动垃圾
CMS 收集器的运行过程:
-
初始标记:标记 GC Roots 能直接到的对象。速度很快但是仍存在 Stop The World 问题
-
并发标记:进行 GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行
-
重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在 Stop The World 问题
-
并发清除:对标记的对象进行清除回收
CMS 收集器的内存回收过程是与用户线程一起并发执行的
G1(Garbage First)
同时注重吞吐量(Throughput)和低延迟(Low latency),默认的暂停目标是 200 ms,会将堆划分为多个大小相等的 Region,整体上是标记-整理算法,两个区域之间是复制算法
特点:
- 并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短Stop-The-World停顿时间。部分收集器原本需要停顿Java线程来执行 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续运行
- 分代收集:G1 能够独自管理整个 Java 堆,并且采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果
- 空间整合:G1 运作期间不会产生空间碎片,收集后能提供规整的可用内存
- 可预测的停顿:G1 除了追求低停顿外,还能建立可预测的停顿时间模型。能让使用者明确指定在一个长度为 M 毫秒的时间段内,消耗在垃圾收集上的时间不得超过 N 毫秒
G1垃圾回收阶段
- Young Collection:新生代垃圾收集,STW,将伊甸园区对象移到幸存区
- Young Collection + CM:新生代垃圾收集 + 并发标记
- Mixed Collection:会对 E、S、O 进行全面垃圾回收
G1 的优化
- JDK 8u20 字符串去重:将所有新分配的字符串放入一个队列,当新生代回收时,G1并发检查是否有字符串重复,如果它们值一样,让它们引用同一个 char[]
- JDK 8u40 并发标记类卸载:所有对象都经过并发标记后,就能知道哪些类不再被使用,当一个类加载器(自定义类加载器)的所有类都不再使用,则卸载它所加载的所有类
- JDK 8u60 回收巨型对象:一个对象大于 region 的一半时,称之为巨型对象,G1 不会对巨型对象进行拷贝,G1 会跟踪老年代所有 incoming 引用,这样老年代 incoming 引用为0 的巨型对象就可以在新生代垃圾回收时处理掉
- JDK 9 并发标记起始时间的调整:并发标记必须在堆空间占满前完成,否则退化为 FullGC,JDK 9 可以动态调整阈值
5. 三色标记法
在并发标记过程中,因为标记期间应用程序还在运行,对象间的引用可能发生变化,多标和漏标的情况可能发生,而三色标记法把 GCRoot 可达性分析遍历对象中遇到的对象,按照是否访问过标记成以下三种颜色
- 黑色:对象已经被垃圾收集器访问过,且这个对象的所有引用都已经被扫描过。黑色的对象代表已经扫描过,是安全存活的,如果有其他对象引用指向了黑色对象,无需重新扫描一遍,也就是扫描完成的对象
- 灰色:对象已经被垃圾收集器访问过,但是这个对象至少存在一个引用还没有被扫描过,也就是正在扫描的对象
- 白色:对象还没有被垃圾收集器访问过,白色对象会被当成垃圾对象
多标-浮动垃圾
在并发标记过程,如果方法运行结束,导致部分局部变量(GCRoot)被销毁,这个 GCRoot引用的对象之前又被扫描过(被标记为非垃圾对象),那么本轮 GC 不会回收这部分内存。这部分应该会回收但是没有会受到的内存,称为浮动垃圾
漏标-读写屏障
漏标会导致被引用的对象被当做垃圾误删除,这是严重 bug,必须解决。有两种方案:增量更新、原始快照(SATB)
- 增量更新就是当黑色对象插入新的指向白色对象的引用关系,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。即:黑色对象一旦新插入了指向白色对象的引用之后,就变为了灰色对象
- 原始快照就是当灰色对象要删除指向白色对象的引用关系时,将这个要删除的引用记录下来,在并发扫描结束后,在将这些记录过的引用关系中的灰色对象为根,就能扫描到白色对象,将白色对象直接标记为黑色(目的就是在本轮GC里活下来,不管是不是垃圾,宁愿不删,也不可错删,等待下一轮 GC 重新扫描,这个对象可能是浮动垃圾)
无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的,写屏障的实现类似于 AOP
CMS使用增量更新,G1使用SATB
CMS 适用于4-8G内存,且 CMS 就一块老年代区域,重新深度扫描对象的代价小于 G1
G1 使用的场景是 32G 或者更大,如果使用增量更新,每次都去遍历每一块 region,由于内存大,耗费时间很长,且 G1 回收只会回收价值列表里价值较大的,如果遍历的 region 价值小,不会被回收,遍历了也没用,反正 region 不会被回收,也就不存在误删情况。所以使用 SATB 将白色对象标记为黑色,等待下一次 GC 再深度扫描
记忆集与卡表
在新生代做 GCRoot 可达性扫描过程中可能会碰到跨代引用的对象,这种如果又去对老年代再去扫描效率太低了。 为此,在新生代可以引入记录集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合),避免把整个老年代加入 GCRoots 扫描范围,hotspot 使用一种叫做卡表(cardtable)的方式实现记忆集
要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成 1,表示该元素变 脏,否则为 0,GC 时,只要筛选本收集区的卡表中变脏的元素加入 GCRoot 里
类加载
虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验,解析和初始化,最终形成可以被虚拟机直接使用的 java 类型
1.类加载过程
加载
将类的字节码载入方法区(1.8后为元空间,在本地内存中)中,如果这个类还有父类没有加载,先加载父类
连接
连接:验证 -> 准备 -> 解析
验证类是否符合 JVM规范,安全性检查;为 static 变量分配空间,设置默认;将常量池中的符号引用解析为直接引用
初始化
静态代码块、static 修饰的变量赋值、static final 修饰的引用类型变量赋值,会被合并成一个 clinit() 方法,初始化阶段就是执行类构造器 clinit() 方法的过程,虚拟机会保证这个类的构造方法的线程安全
tips:static final 修饰的基本类型变量赋值,在链接阶段就已完成
发生时间:类的初始化的懒惰的,以下情况会初始化
- main 方法所在的类,总会被首先初始化
- 首次访问这个类的静态变量或静态方法时
- 子类初始化,如果父类还没初始化,会引发
- 子类访问父类的静态变量,只会触发父类的初始化
- Class.forName
- new 会导致初始化
2.类加载器
Java虚拟机设计团队有意把类加载阶段中的通过一个类的全限定名来获取描述该类的二进制字节流这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为类加载器(ClassLoader)
类与类加载器
类加载器虽然只用于实现类的加载动作,但其在Java程序中起到的作用却远超类加载阶段
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等
名称 | 加载的类 | 说明 |
---|---|---|
Bootstrap ClassLoader(启动类加载器) | JAVA_HOME/jre/lib | 无法直接访问 |
Extension ClassLoader(拓展类加载器) | JAVA_HOME/jre/lib/ext | 上级为Bootstrap,显示为null |
Application ClassLoader(应用程序类加载器) | classpath | classpath |
自定义类加载器 | 自定义 | 上级为Application |
双亲委派模式
当一个类收到了类加载请求时,不会自己先去加载这个类,而是将其委派给父类,由父类去加载,如果此时父类不能加载,反馈给子类,由子类去完成类的加载
双亲委派的目的有两点
- 让上级类加载器中的类对下级共享(反之不行),即能让你的类能依赖到 jdk 提供的核心类
- 让类的加载有优先次序,保证核心类优先加载
3.运行期优化
即时编译
JVM 将执行状态分成了 5 个层次:
- 0层:解释执行(Interpreter),用解释器将字节码翻译为机器码
- 1层:使用 C1 即时编译器编译执行(不带 profiling)
- 2层:使用 C1 即时编译器编译执行(带基本的profiling)
- 3层:使用 C1 即时编译器编译执行(带完全的profiling)
- 4层:使用 C2 即时编译器编译执行
profiling 是指在运行过程中收集一些程序执行状态的数据,例如方法的调用次数,循环的 回边次数等
即时编译器(JIT)与解释器的区别
- 解释器
将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
是将字节码解释为针对所有平台都通用的机器码 - 即时编译器
将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
根据平台类型,生成平台特定的机器码
对于大部分的不常用的代码,无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。 执行效率上简单比较一下 Interpreter < C1 < C2,总的目标是发现热点代码(hotspot 名称的由来),并优化这些热点代码
逃逸分析
逃逸分析(Escape Analysis)简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术
逃逸分析的 JVM 参数如下:
- 开启逃逸分析:-XX:+DoEscapeAnalysis
- 关闭逃逸分析:-XX:-DoEscapeAnalysis
- 显示分析结果:-XX:+PrintEscapeAnalysis
逃逸分析技术在 Java SE 6u23+ 开始支持,并默认设置为启用状态
对象逃逸状态
-
全局逃逸(GlobalEscape)
即一个对象的作用范围逃出了当前方法或者当前线程,有以下几种场景:- 对象是一个静态变量
- 对象是一个已经发生逃逸的对象
- 对象作为当前方法的返回值
-
参数逃逸(ArgEscape)
即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的 -
没有逃逸
即方法中的对象没有发生逃逸
逃逸分析优化
针对上面第三点,当一个对象没有逃逸时,可以得到以下几个虚拟机的优化:
-
锁消除
线程同步锁是非常牺牲性能的,当编译器确定当前对象只有当前线程使用,那么就会移除该对象的同步锁
例如,StringBuffer 和 Vector 都是用 synchronized 修饰线程安全的,但大部分情况下,它们都只是在当前线程中用到,这样编译器就会优化移除掉这些锁操作
锁消除的 JVM 参数如下:
开启锁消除:-XX:+EliminateLocks
关闭锁消除:-XX:-EliminateLocks
锁消除在 JDK8 中都是默认开启的,并且锁消除都要建立在逃逸分析的基础上 -
标量替换
基础类型和对象的引用可以理解为标量,它们不能被进一步分解。而能被进一步分解的量就是聚合量,比如对象
对象是聚合量,它又可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做标量替换
这样,如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能
标量替换的 JVM 参数如下:
开启标量替换:-XX:+EliminateAllocations
关闭标量替换:-XX:-EliminateAllocations
显示标量替换详情:-XX:+PrintEliminateAllocations
标量替换同样在 JDK8 中都是默认开启的,并且都要建立在逃逸分析的基础上 -
栈上分配
当对象没有发生逃逸时,该对象就可以通过标量替换分解成成员标量分配在栈内存中,和方法的生命周期一致,随着栈帧出栈时销毁,减少了 GC 压力,提高了应用程序性能
方法内联
-
内联函数:内联函数就是在程序编译时,编译器将程序中出现的内联函数的调用表达式用内联函数的函数体来直接进行替换
-
JVM内联函数: C++ 是否为内联函数由自己决定,Java 由编译器决定。Java 不支持直接声明为内联函数的,如果想让他内联,你只能够向编译器提出请求: 关键字 final 修饰 用来指明那个函数是希望被 JVM 内联的
一般的函数都不会被当做内联函数,只有声明了 final 后,编译器才会考虑是不是要把函数变成内联函数
4.字节码及其组成
java 字节码(Java bytecode)是 Java 虚拟机执行的一种指令格式
因为 JVM 针对各种操作系统和平台都进行了定制,无论在什么平台,都可以通过 javac 命令将一个 .java 文件编译成固定格式的字节码(.class 文件)供 JVM 使用。之所以被称为字节码,是因为 .class文件是由十六进制值组成的,JVM 以两个十六进制值为一组,就是以字节为单位进行读取
字节码的组成
JVM对字节码的规范是有要求的,要求每一个字节码文件都要有10部分固定的顺序组成:魔术、版本号、常量池、访问标志、当前类索引、父类索引、接口索引、字段表、方法表、附件属性
魔数
所有的 .class 文件的前4个字节都是魔数,魔数以一个固定值:0xCAFEBABE,放在文件的开头,JVM 就可以根据这个文件的开头来判断这个文件是否可能是一个 .class 文件,如果是以这个开头,才会往后执行下面的操作,这个魔数的固定值是 Java 之父 James Gosling 指定的,意为 CafeBabe(咖啡宝贝)
版本号
版本号是魔术之后的4个字节,前两个字节表示次版本号(Minor Version),后两个字节表示主版本号(Major Version),上面的 0000 0032,次版本号 0000 转为十进制是0,主版本号 0032 转为十进制50,对应下图的版本映射关系,可以看到对应的 java 版本号是1.6
常量池
紧接着主版本号之后的字节为常量池入口,常量池中有两类常量:字面量和符号引用,字面量是代码中申明为 Final 的常量值,符号引用是如类和接口的全局限定名、字段的名称和描述符、方法的名称和描述符。常量池整体分为两个部分:常量池计数器以及常量池数据区
访问标志
常量池结束后的两个字节,描述的是类还是接口,以及是否被 Public、Abstract、Final等修饰符修饰,JVM 规范规定了9种访问标示(Access_Flag)JVM是通过按位或操作来描述所有的访问标示的,比如类的修饰符是 Public Final,则对应的访问修饰符的值为ACC_PUBLIC | ACC_FINAL,即0x0001 | 0x0010=0x0011
当前类索引
访问标志后的两个字节,描述的是当前类的全限定名,这两个字节保存的值是常量池中的索引值,根据索引值就能在常量池中找到这个类的全限定名
父类索引
当前类名后的两个字节,描述的父类的全限定名,也是保存的常量池中的索引值
接口索引
父类名称后的两个字节,是接口计数器,描述了该类或者父类实现的接口数量,紧接着的n个字节是所有接口名称的字符串常量的索引值
字段表
用于描述类和接口中声明的变量,包含类级别的变量和实例变量,但是不包含方法内部声明的局部变量,字段表也分为两个部分,第一部分是两个字节,描述字段个数,第二部分是每个字段的详细信息 fields_info
方法表
字段表结束后为方法表,方法表也分为两个部分,第一个部分是两个字节表述方法的个数,第二部分是每个方法的详细信息
方法的访问信息比较复杂,包括方法的访问标志、方法名、方法的描述符和方法的属性:
附加属性
字节码的最后一部分,该项存放了在该文件中类或接口所定义属性的基本信息
内存模型(Java Memory Model)
JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障
1.原子性
i++的JVM字节码指令
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 加法
putstatic i // 将修改后的值存入静态变量i
i–的JVM字节码指令
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 减法
putstatic i // 将修改后的值存入静态变量i
当两个线程同时操作一个共享变量时,可能造成指令重排
指令重排情况
// 假设i的初始值为0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
解决办法:使用 synchronized 关键字上锁
2.可见性
当 t 线程频繁从主存中读取热点数据时,JIT 编译器会将热点数据缓存至高速缓存,减少对主存的访问,提高效率
但是当其他线程改变了主存中的值时,t 线程只能读到旧值,也就造成了可见性的问题
解决方法:使用 volatile 关键字修饰成员变量和静态成员变量,可以强制线程必须从主存中读取值
3.有序性
指令重排除了会造成原子性的问题外,还会造成有序性的问题
int i = 0;
int i++;
int j = i + i;
正常的情况 j 的值应该为 2,但在并发情况下,第三行代码可能在第二行代码前执行, 就会影响程序的逻辑性
解决方法:使用 volatile 关键字禁止指令重排
Happens-Before
Happens-Before 的语义本质上是一种可见性,A Happens-Before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。例如 A 事件发生在线程 1 上,B 事件发生在线程 2 上,Happens-Before 规则保证线程 2 上也能看到 A 事件的发生。
JMM 的设计分为两部分,一部分是面向程序员提供,也就是happens-before规则,通俗易懂的向程序员阐述了一个强内存模型,只要理解 happens-before规则,就可以编写并发安全的程序了。 另一部分是针对 JVM 实现的,为了尽可能少的对编译器和处理器做约束,从而提高性能,JMM 在不影响程序执行结果的前提下对其不做要求,即允许优化重排序。 程序员只需要关注前者就好了,也就是理解 happens-before 规则
Happens-Before 规则
- 程序顺序规则:一个线程中的每一个操作,happens-before 于该线程中的任意后续操作
- 监视器规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁
- volatile规则:对一个volatile变量的写,happens-before 于任意后续对一个volatile变量的读
- 传递性:若果A happens-before B,B happens-before C,那么A happens-before C
- 线程启动规则:Thread 对象的 start() 方法,happens-before 于这个线程的任意后续操作
- 线程终止规则:线程中的任意操作,happens-before 于该线程的终止监测
- 线程中断操作:对线程 interrupt() 方法的调用,happens-before 于被中断线程的代码检测到中断事件的发生
- 对象终结规则:一个对象的初始化完成,happens-before 于这个对象的 finalize() 方法的开始
4.CAS与原子类
CAS
CAS 即 Compare and Swap ,体现的一种乐观锁的思想,比如多个线程要对一个共享的整型变量执行 +1 操作
获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。结合 CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下,因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一,但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
乐观锁与悲观锁
- CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,不断重试
- synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,上了锁都改不了,解开锁其他线程才有机会
原子操作类
juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作
附录:总结图
以上是关于深入理解JVM:内存结构垃圾回收类加载内存模型的主要内容,如果未能解决你的问题,请参考以下文章
深入理解JVM内存结构-垃圾回收-类加载&字节码技术-内存模型