《深入理解JVM》

深入理解JVM


(一)JVM内存模型

【线程共享区域】

1.堆(Heap):GC堆

Heap只需要逻辑上连续,物理上不需要连续(连续则通过指针碰撞分配内存,不连续则通过空闲列表法分配内存)。

(1)主要存储

对象实例(包括java.lang.Class对象实例)、数组、常量池(Java 8后)

随着JIT编译器与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术会导致一些微妙的变化,“所有对象实例以及数组都要在堆上分配”这句话也不那么绝对了。

(2)JVM参数
  1. -Xmx:最大堆大小

  2. -Xms:最小堆大小

    -Xmx = -Xms,最大堆、最小堆大小设置为一样,可以防止Java堆自动扩容。

  3. -Xmn:年轻代(young)大小

  4. -XX:NewRatio:年轻代/老年代的值,默认新生代:老年代 = 1:2,即-XX:NewRatio = 0.5

  5. -XX:SurvivorRatio:年轻代Eden/Survivor的值,设置为8,则Eden:From Survivor:To Survivor = 8:1:1,每次只浪费1/10的空间

  6. -XX:MaxTenuringThreshold:对象最大年龄,一般为15

(3)OOM:内存泄漏/内存溢出

不断创建对象,并且保证GC Roots到对象之间存在可达路径来避免GC,那么对象数量达到最大堆容量就是OutOfMemoryError

  1. 内存泄漏:内存中的对象是不必要的,只是由于某些原因导致GC没有回收它们,这种情况应该检查对象到GC Roots之间的引用链,定位内存泄漏位置
  2. 内存溢出:内存中的对象是必要的,堆大小确实不足

2.方法区(Method Area)

主要存储:已被JVM加载的类信息、常量池(存储编译器生成的各种字面量和符号引用)、静态变量、JIT编译器编译后的代码

Java 8后运行时常量池从方法区移到了堆区

Java8后方法区从永久代(即堆空间)移到了MetaSpace的堆外空间

当方法区无法满足内存分配需求,抛出OutOfMemoryError异常


【线程独占区域】

3.虚拟机栈(JVM Stack)-Xss

(1)单位

栈帧(每个方法从被调用到执行完,就对应一个栈帧在JVM栈中入栈到出栈

(2)主要存储

基本数据类型、对象引用(reference,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄位置)

(3)JVM参数

-Xss
值得注意的是为每个栈分配的内存越大,就越容易产生OOM —— 因为每个进程的内存是有限的,栈内存越大,就会挤压到堆内存,能够产生的线程数就越少

如果是建立线程过多导致的OOM,可以通过缩小最大堆减少栈容量来解决。

(4)内存溢出

虚拟机栈不允许动态扩容,所以可能出现两种溢出:

  1. StackOverFlowError线程请求栈过深
  2. OutOfMemoryError创建栈帧时无法获得足够内存

4.本地方法栈(Native Stack)

主要存储:Native方法执行期间的数据

可能出现两种溢出:

  1. StackOverFlowError线程请求栈过深
  2. OutOfMemoryError创建栈帧时无法获得足够内存

5.程序计数器(PC)

一个用于指示程序运行到了哪一步的指针,用于线程切换。
对于Java方法,PC记录的是正在执行的JVM字节码指令地址;对于Native方法,PC值为空。


【直接内存(Direct Memory)(堆外内存)】

(1)使用场景

  1. NIO会使用Native函数库分配堆外内存,然后通过一个存储在Java堆的DirectByteBuffer对象作为这块内存的引用来进行操作,这样可以避免Java堆和Native堆之间来回复制数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
DirectByteBuffer(int cap){
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);

long base = 0;
try{
//分配堆外内存,返回基地址
base = unsafe.allocateMemory(size);
}catch(OutOfMemoryError x){
Bits.unreserveMemory(size, cap);
throw x;
}
//内存初始化
unsafe.setMemory(base, size, (byte) 0);
if(pa && (base % ps != 0)){
address = base + ps - (base & (ps - 1));
}else{
address = base;
}
//追踪DirectByteBuffer对象的垃圾回收,
//以实现DirectByteBuffer对象被回收时,分配的堆外内存一起释放
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
  1. 堆外内存受OS管控而非JVM,使用堆外内存可以保持较小规模的堆内存,从而减少GC时的停顿时间

(2)JVM参数

-XX: MaxDirectMemorySize
如果不指定,则默认与-Xmx相同

(3)内存溢出

如果这部分内存被频繁使用,可能导致OutOfMemoryError


(二)垃圾回收(GC):主要是堆区

【回收何种垃圾:可达性分析(GC Roots)】

  1. 引用计数法:每一个对象维护一个计数器,对象每增加一个引用,计数器 + 1,每减少一个引用,计数器 - 1。当对象的计数器为0时,该对象被认定为垃圾,需要回收。

    局限性:
    如果两个对象相互引用,但是两个对象其实都是垃圾,使用引用计数法无法识别,会导致OOM。

Java引用类型:
强引用:就是一般对象的引用,只要强引用存在就不会被GC;
软引用:软引用会在系统将要OOM之前由GC进行回收;
弱引用:弱引用会在下次GC时回收;
虚引用:不占用内存,相当于一个指针。

  1. 可达性分析:维护一系列称为GC Roots的对象,从GC Roots向下搜索,如果一个对象到所有GC Roots都不存在引用链,那么这个对象就是垃圾。

    那么什么样的对象可以作为GC Roots呢?
    其实不难想到,GC Roots应该是Java中能够引用其他对象的那些对象,无非就是静态变量、常量和本地变量:

  2. 虚拟机栈中的本地变量
  3. 本地方法栈中的本地变量
  4. 方法区的静态变量
  5. 方法区的常量

注意:GC Roots不包括堆中的大量对象(JDK8后堆中常量池对象除外),这也是ZGC增加堆大小不影响停顿时间的主要原因


【垃圾回收算法:标记-清除/复制/整理】

1.标记-清除算法(Mark-Sweep)

遍历一次所有对象,标记所有需要回收的对象,第二次遍历时进行回收。

缺点:

  1. 两次遍历,耗时长
  2. 存在内存碎片,如果需要分配的对象过大,会提前触发一次full GC
  3. 需要维护一个空闲列表记录未分配的内存区域

2.复制算法(Copying)

将内存分为等容量的两块,每次只使用其中一块,遍历时将存活的对象复制到另一块,直接抹除原来那块。

缺点:半数内存无法使用,代价实在太大。

优化:比如新生代就分为8:1:1的eden : from survivor: to survivor区,每次只有10%的内存是未使用的,而不是一半。


3.标记-整理算法(Mark-Compact)

遍历所有对象,将所有存活的对象向边界移动,抹除除了边界之外的内存区域。

缺点:内存变动频繁,效率低。


【Java堆区:分代收集策略】

1.新生代:复制算法(Young GC

为新对象分配新生代内存时,如果Eden区空间不足,就会触发一次Young GC,只会回收新生代

新生代中对象朝生夕灭,每次GC后存活的对象非常少,所以适合复制算法,只需要付出少量内存的代价。

Java堆分区:EdenSuvivor FromSuvivor To = 8 : 1 : 1。

对象优先分配在新生代的Eden区。

每次GC,会将Eden区存活的对象复制到Survivor From,将Survivor From中存活的对象复制到Survivor ToSurvivor FromSurvivor To是循环的,这次GC时的Survivor From就是下一次GC时的Survivor To,每次只有一个Survivor区的10%内存是不被使用的

为什么需要Survivor区?
因为新生代的对象朝生夕灭,如果每次GC存活的对象直接进入老年代,老年代压力会过大,而且此次存活的对象一般很难活过下几次GC

为什么需要两个Survivor区?
主要是为了防止内存碎片化。试想如果只有1个Survivor区,Eden区存活的对象经过GC进入Survivor区,下一次GC如果Survivor区存在需要回收的对象,我们只能选择标记-清除算法,回收之后Survivor区就存在碎片化的内存。

那么什么样的对象可以从新生代进入老年代?

  1. 大对象:新生代分配不下的大对象,如极长的数组,直接进入老年代
  2. 存活年龄超过15的对象:新生代的对象每存活1次GC年龄就 + 1,年龄超过15后直接进入老年代
  3. 动态对象年龄:如果新生代某个时间内,Survivor区中相同年龄的对象超过了1/2Survivor区空间,那么大于等于这个年龄的对象就可以直接进入老年代,无需等待15岁。

2.老年代:标记-清除/整理算法(Major GC

将新生代对象转移到老年代时(或大对象直接分配老年代/或MetaSpace内存不足),如果老年代/MetaSpace空间不足,就会触发Major GC,回收整个MetaSpace和堆内存

老年代中对象存活率高,无法保证足够的空间分配,所以不能用复制算法。
只有在Major GC时才进行回收,且每次GC都伴随着“Stop-The-World”。


3.永久代

在HotSpot JVM中,永久代等价于方法区,用于存储JAR包、Class文件、常量池等,逻辑上属于堆的一部分,但是为了和堆作区分,通常又叫“非堆”。

永久代主要回收两部分内容:废弃的常量 、 无用的类
一个类要满足3个条件才能被判定为是无用类:
1.该类的实例都已经被回收,堆中不存在该类的实例
2.该类的类加载器被回收
3.该类对应的Class对象没有在任何地方被引用,无法通过反射访问该类

在JDK8中,JVM移除了永久代PermGen,取而代之的是元空间MetaSpace——元空间是JVM外的本地内存


(1)为什么JDK8采用MetaSpace取代PermGen
  1. JDK8之前永久代PermGen等价于方法区,使用的是堆内存,永久代空间不足容易发生java.lang.OutOfMemoryError: PermGen
  2. MetaSpace则是使用的堆外内存,即机器的本地内存,取决于机器的虚拟内存大小,只要配置得当就不会出现OOM;同时多个项目共享同样的MetaSpace,一些可以复用的Class比如gson包只会存一份,不仅提高了内存利用率,也更有利于GC
  3. MetaSpace只有full gc时才扫描,每次分配的都是线性内存,不会有重定位和内存压缩的开销
  4. JRockit VM没有永久代,移除永久代可以促进HotSpot VM和JRockit VM融合

(2)原来PermGen的数据存哪里去了?
  1. 方法区(Class文件、JAR包……)移至MetaSpace
  2. 常量池移至Java Heap

(3)MetaSpace常见JVM参数
  1. -XX:MetaspaceSize=21M初始元空间大小,即GC阈值,默认21M

    java -XX:+PrintFlagsInitial:查看本机初始化参数

如果初始元空间太小,JVM启动时会因为无法将所有类加载到MetaSpace,导致Full GC的发生

  1. -XX:MaxMetaspaceSize=80m元空间最大值,防止MetaSpace无限制使用本地内存,影响其他程序,默认4096m(4294967295B)

  2. MinMetaspaceFreeRatio默认40,即40%,元空间空闲比小于此值,就会自动扩容

  3. MaxMetaspaceFreeRatio默认70,即70%,元空间空闲比大于此值,就会释放部分内存

  4. MaxMetaspaceExpansion元空间扩容幅度,默认5m

  5. MinMetaspaceExpansion元空间缩容幅度,默认330kb


(4)MetaSpace GC日志

参考自https://www.jianshu.com/p/cd34d6f3b5b4,写得很好。

1
2
3
4
5
...
Metaspace used 54029K, capacity 59932K, committed 60076K, reserved 1101824K
class space used 6342K, capacity 8087K, committed 8140K, reserved 1048576K
2019-11-04T01:07:27.480+0800: 7.365: [Full GC (Metadata GC Threshold) 2019-11-04T01:07:27.480+0800: 7.365: [CMS: 38182K->160379K(2621440K), 0.4968647 secs] 1270443K->160379K(4037056K), [Metaspace: 54029K->54029K(1101824K)], 0.4975301 secs] [Times: user=0.61 sys=0.03, real=0.50 secs]
...

从上述日志,可以看到class space(方法区)虽然被移到了MetaSpace,但仍然是单独占有一块;而MetaSpace被分成了4部分:

  1. reservedOS承诺为该进程保留的一大块连续内存,但是不一定会有真正对应的物理内存

    轻诺必寡信╰(‵□′)╯,操作系统果然是大猪蹄子

  2. committed当进程真正需要使用该连续内存时,OS才会分配真正的物理内存,但是这个过程一般会由于承诺给你的连续内存已经被分出去了而失败

  3. capacity:总容量,当容量不足时会扩容,但是容量不会超过committed

  4. used:已使用容量

上述GC日志中就是由于MetaSpace空间不足导致了Full GC的发生,committed最大可扩容容量capacity最大容量,而reserved不具备参考价值,只是OS随口许下的承诺。
我们可以推测出上述Full GC发生的原因:需要分配MetaSpace内存空间时(比如类加载),现有空间不足,所以尝试扩容;扩容时发现MetaSpace物理机内存不足,无法分配,导致Full GC提前发生
当然还有这样的情况:该Full GC发生在JVM启动时,由于-XX:MetaspaceSize使用了默认的21M,服务器启动时类加载时空间不足,导致Full GC的发生。这样的问题后续会随着MetaSpace自动扩容得到解决


【垃圾收集器(GC)】

1.串行GC:-XX:+UseSerialGC

新生代:Serial
老年代:Serial Old

使用单线程进行GC,适用于新生代空间较小、对暂停时间要求不是很高的程序。

优点:实现简单;
缺点:GC线程收集时STW(Stop-The-World)时间过长。

JVM参数:-XX:+UseSerialGC


2.并行GC:-XX:+UseParallelGC & -XX:+UseParNewGC & -XX:+UseParallelOldGC

新生代:ParNewParallel Scavenge
老年代:Parallel Old

使用多线程进行GC,但是要注意GC线程运行时同样会STW,不会与用户线程并发执行

优点:使用多线程,降低了STW时间;
缺点:仍然会STW。

JVM参数:-XX:+UseParallelGC,通过-XX:+ParallelGCThreads=4可以指定并行线程数。


3.CMS(Concurrent Mark-Sweep)垃圾收集器:-XX:+UseConcMarkSweepGC

使用多线程并发进行GC,GC线程和用户线程并发执行
CMS一般都要搭配ParNew使用

Java 9CMS被废除。

缺点
  1. 仍然使用的是标记-清除算法,同样具有耗时、内存碎片、空闲空间表的问题;
  2. 存在“浮动垃圾”——在GC进行的同时,用户线程产生了垃圾;
  3. 多线程并发的CPU负担比较重
  4. CMS只能作用于老年代,需要搭配ParNew才能对新生代进行GC
JVM参数

-XX:+UseParNewGC

工作过程

CMS流程

  1. 初始标记(initial mark):短暂的STW,采用单线程,简单标记应用代码中可以直接访问到的存活对象

  2. 并发标记(concurrent mark):采用单线程,并发标记第一步中标记的对象可以直接访问到的所有存活对象,由于CMS是并发的,所以不保证该步骤所有存活对象都被标记

  3. 重标记(remark):短暂的STW,采用多线程,重新访问第二步运行过程中修改了的对象,保证所有存活对象都被标记

    标记时间会随着堆内存的增加而成比例增加,这就是G1垃圾收集器出现的原因,希望能够让停顿时间和堆内存无关、并且可控。

  4. 并发清除(concurrent sweep):单线程,并发清除所有未标记对象

    可以看到CMS只有remark阶段为了追求效率,采取了多线程并行,其他步骤都是单线程。

initial mark和remark阶段有两个简单的STW


4.G1垃圾收集器:-XX:+UseG1GC,标记-整理

G1收集器在JDK9就已经成为了默认的GC收集器,适用于大内存、多CPU、多核环境,并发并行地进行GC,可以作用于新生代、老年代,并且可以预测停顿时间

如果你追求的是短暂可控的STW时间,那么G1是很好的选择,可以断言要优于CMS + ParNew;但是如果你追求的是高吞吐量,那么G1并不会有很大的优势。

工作过程

  1. 初始标记:短暂的STW,单线程
  2. 并发标记:并发,将STW期间对象变化记录在Remembered Set Logs里面
  3. 重标记:短暂的STW,多线程并行,将Remembered Set Logs的数据合并到Remembered Set中
  4. 清除:CMS唯一的不同在于G1的清除阶段是STW的,而非并发清除
算法:【局部】复制,【整体】标记-整理
  • 从局部来看,G1采用的是Copying算法,在young GC时会将eden region存活的对象拷贝到survivor region/old region不会产生内存碎片
  • 从整体来看,G1采用的是Mark-Compact算法,会对堆内存进行压缩,从而减少内存碎片的产生

    但是要注意,最开始的G1在整理完内存后,并不会马上将内存还给操作系统,直到JDK12才做出优化。


基本概念
(1)Region
  • 将堆区域划分成很多个固定大小Region(默认为2048个),每个Region占有一块连续的虚拟内存地址,不再分配连续内存的新生代、老年代,GC时也不再区分新生代、老年代,而是会GC所有region。每一个Region根据垃圾的比例设置一个权重,在后台维护一个优先列表,每次GC时回收权重最大的,避免全堆GC
  • Region可以分为EdenSurvivorOldHumongous4种,其中humongous可以跨多个region,用于存放巨大对象(humongous object, H-obj)—— 即大小>= 1/2 Region的对象H-obj直接分配在老年代,防止反复拷贝移动。
  • 虽然G1不再区分新生代、老年代,但是分代收集的思想仍然在Region回收中得以保留(见下节的young gc/mixed gc/full gc

(2)RSetRemembered Set
  • RSet是一种辅助GC的数据结构,属于以空间换时间:每一个Region都有一个RSet,记录其他Region中的对象引用本Region中对象的关系
  • young gc时:只需要选定年轻代RegionRSet作为可达性分析的根集,这些RSet中记录了老年代 -> 年轻代的跨代引用,避免了扫描整个老年代
  • mixed gc时:老年代RegionRSet中记录了老年代 -> 老年代的引用,年轻代 -> 老年代的跨代引用通过扫描整个年轻代RegionRSet获得,这样就不需要扫描整个老年代Region,减少了GC的工作量


分代GC模式
  1. young GC:当所有eden region被耗尽,无法分配新的内存就会触发一次yong GC;执行完一次young GC后,活跃的对象会被拷贝到survivor region或者直接晋升old region,和普通young GC类似。
  2. mixed GC:当越来越多对象晋升到old region,比例达到一个阈值后(默认45%,可以通过-XX:InitiatingHeapOccupancyPercent进行设置),为了避免堆内存倍耗尽,就会触发一次mixed GC回收eden region和一部分old region
  3. 通过第2点可知:mixed GC不是full GC,它只能回收部分老年代的old region如果mixed GC实在无法跟上内存分配的速度,就会导致老年代填满无法继续进行mixed GC,此时会使用serial old GCfull GC)来收集整个堆空间,该收集器是单线程的,会STW,效率很低

    要注意,G1不提供full GC的!

JVM参数
  1. 通过-XX:+UseG1GC启用G1垃圾收集器。
  2. 通过-XX:+G1HeapRegionSize指定每一个region的大小,只能是1/2/4/8/16/32M
  3. -XX:MaxGCPauseMillis:设置G1垃圾收集目标停顿时间,默认200ms,非必须;
  4. -XX:G1NewSizePercent:新生代最小值,默认5%
  5. -XX:G1MaxNewSizePercent:新生代最大值,默认60%
  6. -XX:ParallelGCThreads重标记STW阶段并行线程的数量
  7. -XX:ConcGCThreads并发标记期间,并行执行的线程数量
  8. -XX:InitiatingHeapOccupancyPercent触发mixed GCold region + humongous region堆占用率阈值,默认45%
JDK12中对G1垃圾收集器做出的两个优化
  1. 由于G1会尽可能选择垃圾较多的region,所以停顿时间可能超过预期时间,这时允许中止部分可选回收以达到设定的停顿时间目标

    回收会被分为“必须”和“可选”两种。

  2. 可以在空闲时自动将Java堆内存返回给操作系统


5.ZGC:标记-整理

JDK11新引入的GC。

工作过程
  1. 初始停顿标记(Pause Mark Start):STW,标记GC Roots可以直接到达的对象

(一)为什么ZGC可以做到几个T的堆内存上10ms以内的停顿时间?
因为ZGC只有初始停顿标记阶段会根据GC Roots来STW,而GC Roots的数量有限且不包括堆对象,所以ZGC的STW时间非常短暂,同时不管堆大小如何增加,GC Roots的数量都不会增加,所以不会影响停顿时间

(二)为什么ZGC的停顿时间要比G1垃圾收集器少那么多?
因为ZGC只有初始停顿标记阶段会STW,和GC Roots数量有关,而G1的回收是根据每个region的垃圾比例来判断的,停顿时间和GC Roots的数量无关,所以G1的停顿延迟要比ZGC大很多

(三)那么是不是ZGC就一定要比G1垃圾收集器要好?
并不是!ZGC只是STW的时间比较短,但是其吞吐量(单位时间内处理数量)相比于G1是要降低大概15%的。

  1. 并发标记(Concurrent Mark):沿着初步标记的对象并发地标记其它可达对象
  2. 移动对象(Relocate):将所有存活对象移动到最右端的region,记录转向表,释放其他region的空间
  3. 修正指针(Remap):根据上一步记录的转向表,将存活对象的指针指向新地址
优点
  1. 几乎所有阶段都是并发执行的
  2. 同样划分region,不同于G1的是ZGC的region大小是动态分配的,不同大小的对象分配在不同大小的region
  3. 采用的是Mark-Compact算法,不存在内存碎片的问题
  4. 不再区分新生代、老年代
JVM参数

-XX:+UnlockExperimentalVMOptions -XX:+UseZGC


(三)对象的创建

1.类加载检查

虚拟机遇到一个new指令时,首先将检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析、初始化。如果没有,则必须先执行类加载。

详见:(六)类加载机制

2.分配内存

对象所需内存大小在类加载后就可以完全确定
对象主要分配在新生代的Eden区,如果启用了TLAB,则优先在本地线程分配缓冲区上分配

内存分配的方式

  1. 指针碰撞:适用于连续内存,比如“标记-整理”算法收集过后的内存
  2. 空闲列表:存在内存碎片时只能使用空闲列表,比如“标记-清除”算法收集过后的内存

内存分配的“线程安全”措施

  1. CAS + 失败重试
  2. 线程私有的分配缓冲区TLAB:每个线程在堆内预先分配一小块内存,只在自己的独占内存中分配内存

    JVM参数:-XX: +/-UseTLAB

3.初始化零值

将内存空间都初始化为零值(不包括对象头),这样可以保证对象不赋初值就可以直接使用

4.设置对象头

对象是哪个类的实例、如何才能找到类的元数据信息(reference)、hashCode、对象的GC分代年龄、偏向锁……

5.执行init()方法

执行init(),根据程序员意愿初始化对象。


(四)对象的内存布局

  1. 对象头:
  • Mark Word:对象自身运行时数据,包括hashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
  • 类型指针(指向类元数据的指针,用于确认对象是哪个类的实例)
  • ③如果是数组,那么还会在对象头中记录数组长度
  1. 实例数据:对象真正存储的有效信息,即程序中定义的各种类型的字段内容
  2. 对齐填充:仅仅起到占位符的作用,因为JVM要求对象大小必须是8的整数倍

(五)对象的访问定位:reference

通过栈上的reference数据来操作堆上的具体对象

对象访问方式

  1. 句柄:reference存储的是对象句柄地址


    优点:对象地址变动,不会影响reference的句柄,不需要修改
    缺点:查找对象需要两次指针定位

  2. 直接指针:reference存储的是对象地址


    优点:一次指针定位就能查找到对象
    缺点:对象地址变动会影响reference

HotSpot JVM采用的是直接指针


(六)类加载机制

JVM将描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被JVM直接使用的Java类型,这就是虚拟机的类加载机制。

1.加载:二进制字节流到内存

  1. 通过类的全限定名获取其二进制字节流

    事实上JVM没有规定二进制字节流要从哪里获取、怎么获取,也不一定要从一个Class文件中获取。开发人员可以从ZIP包获取(如JAR/WAR)、从网络中获取(Applet)、运行时计算生成(java.lang.reflect.Proxy动态代理)、由其他文件生成(JSP)、从数据库获取……
    JVM设计团队将这个动作放到JVM外部去实现,实现此功能的代码模块称为“类加载器”

  2. 将字节流所代表的静态存储结构转化为方法区的运行时数据结构

  3. 在内存中生成一个代表该类的java.lang.Class对象,作为方法区数据的访问入口

    方法区中存储的是类加载的数据,java.lang.Class对象作为这些数据的入口,存储在堆区,而不是方法区

相对应的存储在Class对象中的static变量,也同样存储在堆区,而不是方法区

详见(七)类加载器

2.连接

2.1 验证:二进制文件符合规范

验证的目的是保证Class文件字节流中包含的信息满足当前JVM的需求,并且不会危害JVM自身的安全

阶段
  1. 文件格式验证:验证字节流是否符合Class文件格式的规范,保证字节流能正确解析并存储于方法区内
  • 是否以0xCAFEBABE开头
  • 主次版本号是否在当前虚拟机处理范围内
  • 常量池常量是否有不支持的类型
  • 指向常量的各种索引值是否有指向不存在的常量或不符合类型的常量
  1. 元数据验证:对字节码描述的信息进行语义解析,保证其描述的信息符合Java语言规范的要求
  • 这个类是否有父类:除了Object,所有类都应该有父类
  • 这个类的父类是否继承了不允许被继承的类,即final
  • 这个类如果不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
  • 类中的字段、方法是否与父类矛盾,比如覆盖了父类的final字段
  1. 字节码验证:通过数据流和控制流的分析,确定程序语义是合法的、符合逻辑的
  2. 符号引用验证:符号引用转化为直接引用(在解析阶段才发生)时的验证

2.2 准备:static变量分配内存 & 赋初值

正式为类变量分配内存,并设置初始值,类变量(static)所用内存将在方法区分配

注意:只为static变量分配内存,不包括实例变量

2.3 解析:直接引用替换符号引用

将常量池的符号引用替换为直接引用

5.初始化:clinit()

除了加载阶段可以通过重载loadClass()参与外,验证、准备、解析阶段都是由JVM主导和控制。
一直到初始化,才会真正执行类中定义的Java程序代码,程序员可以通过主观计划去初始化类变量和其他资源。

初始化时机

  1. 遇到new/getstatic/putstatic/invokestatic这4条字节码指令时,如果类没有初始化,则需要触发其初始化
  2. 遇到java.lang.reflect包的方法对类进行反射调用时,如果类没有进行过初始化,则需要触发其初始化
  3. 当初始化一个类发现其父类没有初始化,则需要先触发父类的初始化
  4. JVM启动时会初始化主类
  5. java.lang.invoke.MethodHandle实例的解析结果为REF_getStatic/REF_putStatic/REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要触发其初始化

(七)类加载器

类加载器是:实现“通过一个类的全限定名获取描述该类的二进制字节流”这个动作的代码模块

作用

  1. 加载类
  2. 对于任意类,都需要这个类本身和类加载器一起确认其在JVM中的唯一性

    换言之,即使两个类来自同一个Class文件,但若加载它们的类加载器不同,那么这两个类就不相等。

种类

  1. 启动类加载器(Bootstrap ClassLoader):C++实现,是JVM自身的一部分,负责加载<JAVA_HOME>\lib目录中的类

    除了Bootstrap ClassLoader之外的所有类加载器都是用Java实现的,独立于JVM,都继承自java.lang.ClassLoader

  2. 扩展类加载器(Extension ClassLoader):负责加载<JAVA_HOME>\lib\ext目录中的类

  3. 应用程序类加载器(Application ClassLoader):负责加载用户类路径ClassPath上指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过类加载器,一般情况下应用程序类加载器就是程序中默认的类加载器

  4. 自定义类加载器:重载loadClass()方法

双亲委派模型

  • 双亲委派模型(Parents Delegation Model)要求除了顶层的启动类加载器,其余所有的类加载器都要有自己的父类加载器
  • 如果一个类加载器收到了类加载请求,它首先会把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(搜索范围内没有找到所需的类),子类加载器才会尝试自己去加载。

好处:Java类随着它的类加载器一起具备了一种带有优先级的层级关系,避免了重复加载

双亲委派模型的破坏

  • 比如:JNDI的代码由启动类加载器加载,但是JNDI的目的是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在ClassPath下的JNDI接口提供者代码,启动类加载器不可能“认识”这些代码,怎么办?
  • 为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader),这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它会从父线程中继承一个,如果应用程序全局范围都没有设置过,那么这个类加载器默认就是应用程序类加载器。
  • 有了这个类加载器,JNDI服务就可以使用它去加载所需要的SPI代码,也就是说父类加载器请求子类加载器去完成类加载,这种行为破坏了双亲委派模型。

Class.forName()ClassLoader

  • 相同点:Class.forName()底层是通过调用ClassLoader实现的,两者都是为了完成类的加载。

  • 不同点:ClassLoader只负责类的加载,不负责类的初始化Class.forName()不止完成了类的加载,同时还默认对加载的类进行了初始化,即完成了对static静态代码块和static静态方法的执行、static静态变量的赋值等操作

    可以通过Class.forName(String name, boolean initialize, ClassLoader loader)传入一个falseinitialize参数,让Class.forName()ClassLoader一样,只加载类,而不去初始化

  • Class.forName()源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@CallerSensitive
public static Class<?> forName(String className) throws ClassNotFoundException{
//通过反射获取调用者的类信息,以便获取其类加载器
Class<?> caller = Reflection.getCallerClass();
//通过ClassLoader对类进行加载
//第二个参数boolean initialize默认为true,【表示会对加载的类进行初始化】
return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}

@CallerSensitive
public static Class<?> Class.forName(String name, boolean initialize, ClassLoader loader) throws ClassNotFoundException{
//……
return forName0(name, initialize, loader, caller);
}

//Native方法
private static native Class<?> forName0(String name, boolean initialize, ClassLoader loader, Class<?> caller) throws ClassNotFoundException;

各自的应用场景

  1. Spring框架中的IoC使用的是ClassLoader因为只需要完成Bean的加载,Bean可能存在lazy-init的特性,其初始化交给IoC容器进行控制更合理
  2. JDBC中使用Class.forName()来加载数据库连接驱动Driver这是因为JDBC要求Driver必须向DriverManager注册,这一步是在静态代码块中完成的,所以需要在加载的时候初始化
1
2
3
4
5
6
7
8
9
10
11
12
public class Driver extends NonRegisteringDriver implements java.sql.Driver{
//在静态代码块中,将Driver注册到DriverManager,所以需要Class.forName()在加载时进行初始化
static{
try{
java.sql.DriverManager.registerDriver(new Driver());
}catch(SQLException e){
throw new RuntimeException("Can't register driver!");
}
}

public Driver() throws SQLException {}
}

(八)编译

编译器分类

  1. 前端编译器:.java文件转为.class文件的过程,比如javac

    javac的编译过程:
    ①解析与填充符号表过程,包括词法分析、语法分析。
    ②插入式注解处理器的注解处理过程。
    ③语义分析与字节码生成过程,包括解语法糖

  2. 后端编译器:JIT编译器(Just In Time Compiler),将字节码转变为机器码的过程

  3. 静态提前编译器:AOT编译器(Ahead Of Time Compiler),直接将.java文件转为机器码的过程

编译优化

1.编译期优化:解语法糖

编译期优化主要是解语法糖:

  1. 泛型与类型擦除:Java中的泛型只在源代码存在,在编译器会使用实际类型进行替换
  2. 自动装箱、自动拆箱
  3. 条件编译

2.运行期优化:JIT

 
JIT编译器(Just-In-Time Compiler,即时编译器)会在程序运行期间,将“热点代码”编译成原生指令码,从而大大提高运行速度


2.1 什么样的代码是“热点代码”?
  1. 被多次调用的方法
  2. 被多次执行的循环体

2.2 怎么样检测“热点代码”?
  1. 基于采样的热点检测:JVM周期性地检查所有线程的栈顶,如果发现某个方法经常出现在栈顶,则判定它是“热点代码”
  2. 基于计数器的热点探测:为每个方法(甚至是代码块)维护一个计数器,统计方法的执行次数,如果超过了一定的阈值就认定它是“热点代码”

    HotSpot JVM采用的是基于计数器的热点探测


2.3 逃逸分析
  • 方法逃逸:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递。
  • JIT编译器分析创建对象的使用范围,如果对象作用域只限于方法内部,就会被分配到栈上,而不会在堆上创建
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//sb逃逸
public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}

//sb未逃逸
//在JIT编译器优化时,会判断对象作用域没有逸出,从而分配到栈上
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
将对象分配到栈上的优点
  • 直接在栈上访问对象,方法结束栈空间就被回收了;无需在堆上分配内存,则无需GC
-------------本文结束感谢您的阅读-------------

本文标题:《深入理解JVM》

文章作者:DragonBaby308

发布时间:2019年07月22日 - 07:25

最后更新:2020年04月13日 - 23:50

原始链接:http://www.dragonbaby308.com/jvm/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

急事可以使用右下角的DaoVoice,我绑定了微信会立即回复,否则还是推荐Valine留言喔( ఠൠఠ )ノ
0%