JVM学习笔记(自用)
文章目录
1.简介
Java Virtual Machine - java 程序的运行环境(java 二进制字节码的运行环境)
好处:
- 一次编写,到处运行
- 自动内存管理,垃圾回收功能(早期优势)
- 数组下标越界检查(早期优势)
拓展:
JRE(JVM+基础类库)
JDK(JVM+基础类库+编译工具)
开发JavaSE程序(JDK+IDE工具)
开发JavaEE程序(JDK+应用服务器+IDE工具)
内容:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-M0amo4OY-1668941551564)(E:\学习笔记\JVM学习笔记\assets\1.png)]
2.程序计数器
java中程序计数器是用寄存器( CPU 内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果)实现的,它的作用是寻找下一个要执行的程序。
当我们的java程序被编译成二进制字节码文件后,如下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QyS5ECA9-1668941551567)(E:\学习笔记\JVM学习笔记\assets\2.png)]
左面是二进制字节码形式(.class),它们将由我们的解释器来将他们转换为机械码,从而让机器运行。
每个二进制字节码的前面都有一个类似于索引的数字。他们的作用也跟索引差不多,为当前程序标一个序号,记上他们的地址。
But:即使有了地址,解释器也不知道他们的顺序是什么样的,他只负责运行。
于是,便有了程序计数器
程序计数器记下了字节码运行的顺序,每当一行字节码走完,他就会立即告诉解释器下一个该走哪里。
特点:
线程私有
为了保证每个线程独立,高效的工作,每个线程都会有一个只属于自己的程序计数器,方便记录自己的程序执行到哪里。
不会有内存溢出(规范中已规定,不必再考虑)
3. 虚拟机栈
每个线程运行时所需要的内存,称为虚拟机栈。
在jvm里,方法的临时储存是在栈里完成的。
每个栈由多个**栈帧(Frame)**组成,对应着每次方法调用时所占用的内存
线程里的方法是逐次的进入对应的栈里的,最顶上的方法是当前执行的,被称为活动栈桢。
每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。 (正在运行的方法)
方法全部执行完后,会反着退出栈。之后被清除。
相关问题:
-
垃圾回收是否涉及栈内存?不涉及,因为栈的运行结束后会按从顶至底的顺序移除栈对应的线程的方法,所以不需要垃圾回收机制来处理长久不用的垃圾。
-
栈内存分配越大越好吗?不是的,我们的物理内存是有限的,如果栈内存分配过大,会导致我们能运行的线程数变少。
-Xss1m 内存调整
-
方法内的局部变量是否线程安全?如果线程的变量没被static修饰的话,即使有多个线程来运行这个代码,结果也是稳定的。
大致情况:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FbOkEXhR-1668941551569)(E:\学习笔记\JVM学习笔记\assets\3.png)]
如果加了static:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-R9XU3PRo-1668941551570)(E:\学习笔记\JVM学习笔记\assets\4.png)]
情况分析:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9ytAEYCp-1668941551573)(E:\学习笔记\JVM学习笔记\assets\5.png)]
实际问题:
-
栈内存溢出:一般指的就是栈桢的数量过多,超过了栈的大小
导致栈帧数量过多的一大原因就是,递归函数
-
cpu 占用过多:通过具体定位来解决
-
top 命令,查看是哪个进程占用 CPU 过高
-
ps H -eo pid, tid(线程id), %cpu | grep
利用刚才通过 top 查到的进程号 通过 ps 命令进一步查看是哪个线程占用 CPU 过高
-
jstack 进程 id
通过查看进程中的线程的 nid ,刚才通过 ps 命令看到的 tid 来对比定位,注意 jstack 查找出的线程 id 是 16 进制的,需要转换。
-
补充
本地方法栈:一些带有Native关键字的方法就是需要JAVA去调用本地的C或者C++方法,因为JAVA有时候没法直接和操作系统底层交互,所以需要用到本地方法。
#3 堆
定义:通过new关键字创建的对象都会被放在堆内存
特点:
- 所有线程共享,堆内存中的对象都需要考虑线程安全问题
- 有垃圾回收机制
堆内存溢出问题(java.lang.OutofMemoryError :java heap space.)
补充:-Xms 规定的空间大小
通过堆内存诊断工具(java自带)来解决
jps:直接输入jps查看所有进程
jmap:jmap -heap 线程编号 查看对应线程堆的情况
jconsole
jvirsalvm:jvm的图形化显示(里面的heapjump(堆转储) 可以对当前情况进行快照)
4. 方法区
方法区是一个概念,它包括常量池
+ClassLoader
+Class
还有串常量(StringTable)。
在逻辑上,方法区算是堆内存的一部分使用的是堆的永久代的内存,但是在实际实现上,不一定用堆的内存。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Tp7wkePV-1668941551575)(C:\Users\29851\AppData\Roaming\Typora\typora-user-images\image-20221119121122513.png)]
而在1.8之后增加了元空间这种概念,将方法区的实现从堆内存改到了操作系统内存
常量池:在java用于保存在编译期已确定的,已编译的class文件中的一份数据。它包括了关于类,方法,接口等中的常量,也包括字符串常量
查看常量池的方法–通过反编译查看字节码文件:
1.获得对应类的.class文件:(在JDK对应的bin目录下运行cmd,也可以在IDEA控制台输入)输入 javac 对应类的绝对路径
>javac E:\Thread_study\src\com\cui\day01\Main.javaCopy
2.在控制台输入 javap -v 类的绝对路径
javap -v E:\Thread_study\src\com\cui\day01\Main.classCopy
常量池:就是一张表(如控制台中显示的constant pool),虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息
运行时常量池:常量池是*.class文件中的,当该类被加载以后,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
常量池中的字符串仅是符号,只有在被用到时才会转化为对象储存到堆里或者串池里。
串池(StringTable):方法区的一员,1.6之前串池在常量池里储存在逻辑上的堆内存的永久代里。
因为永久代需要重GC清理,所以1.8之后对串池的位置进行了更改,使他物理意义上脱离了大多数方法区成员的位置,不在元空间(本地内存)里。
串池是用来保存String对象的,当常量池的字符被String对象引用时,若串池里无重复的对象,将该对象加入到串池。以后若再遇到相同String对象引用相同的字符串则直接使用串池里的对象。(拼接的时候不用,用的是堆内存)
字符串对象的创建都是懒惰的,只有当运行到那一行字符串且在串池中不存在的时候(如 ldc #2)时,该字符串才会被创建并放入串池中。
串池的存在避免了字符串对象的重复创建。
- 字符串变量拼接的原理是StringBuilder,拼接后的对象放在堆内存里。
- 字符串常量拼接的原理是编译器优化,串池里如果有你拼接完的字符串则直接返回,没有则创建一个加入到串池。
intern方法
1.8:会将该字符串对象尝试放入到串池中。(无论放入是否成功,都会返回串池中的字符串对象)(此时如果调用intern方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象)
- 如果串池中没有该字符串对象,则放入成功,返回引用的对象
- 如果有该字符串对象,则放入失败,返回字符串里有的该对象
1.6:会将该字符串对象尝试放入到串池中(此时无论调用intern方法成功与否,串池中的字符串对象和堆内存中的字符串对象都不是同一个对象)
- 如果串池中没有该字符串对象,会将该字符串对象复制一份,再放入到串池中,返回的是复制的对象
- 如果有该字符串对象,则放入失败,返回串池原有的该字符串的对象
串池垃圾回收
很多人感觉串池不能垃圾回收,但实际上,串池也是可以进行垃圾回收的。
用-XX:+PrintStringTableStatistics
显示字符串常量池详细信息。
用-XX:+PrintGCDetails -verbose:gc 打印 gc 的次数,耗费时间等信息
将内存设置为10M后,超过限制后便会触发垃圾回收
StringTable 性能调优
- 增加桶的个数
因为StringTable(串池)底层是hashmap,实现了去重的功能,所以它的性能跟桶的个数(链表的节点数息息相关),桶数越多,性能越强,所以,当我们数据量较大的时候,适当增加桶的个数,能有效的提高效率。
-XX:StringTableSize=桶个数(最少设置为 1009 以上)
- 尽量不使用堆进行字符串储存
同时,用堆内存储存字符串相对于用StringTable储存字符串,因为不能去重,占用的内存资源较大,所以我们尽量用intern()
函数将字符串加入到串池。去掉重复的。
5. 直接内存
直接内存指的就是Direct Memory,常见于Nio操作,区别于IO,在读写操作时有着更高的效率。
它实际上不属于jvm,应该算是一种开辟更快的读写内存的机制。
特点:
- 常见于 NIO 操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受 JVM 内存回收管理
回收机制:
直接内存不能使用JVM的垃圾回收进行处理,他有着自己的回收处理机制
直接内存相对于IO,省去了中间的系统内存缓存区向java缓存区文件的复制操作。大大提高了速率。
IO读写文件的机制如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-u9Pm1UOx-1668941551577)(E:\学习笔记\JVM学习笔记\assets\7.png)]
而直接内存读写的机制如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-w2mgY0su-1668941551579)(E:\学习笔记\JVM学习笔记\assets\8.png)]
无法被jvm回收所导致的内存溢出
直接内存对象如果不清空没办法被jvm垃圾回收进行回收,所以释放它就需要其他函数。
在allocateDirect()
函数里:用unsafe函数释放直接内存(直接内存的释放原理)
显式gc导致的直接内存难以回收的应对方法:手动unsafe释放内存
显式gc指的就是System.gc()
指的是程序员写的gc
6. 垃圾回收
判断类是不是垃圾的办法:分别是 引用计数法
和可达性分析
。
引用计数法:
引用计数法有个弊端,循环引用时,两个对象的计数都为1,导致两个对象都无法被释放
eg:A对象引用了B对象,同时B对象引用了A对象,导致了循环引用
可达性分析(jvm使用)
通过可达性分析来探索所有存活的对象:扫描堆中的对象,看能否沿着GC Root对象为起点的引用链找到该对象,如果找不到,则表示可以回收
可达性分析测试方法:使用jmap工具
获取当前线程后
获取当前状态快照:jamp -dump:format=b,live.file=1,bin 线程
将这个快照文件放到
eslipce
的Memory Analysis里进行解析,得到相关对象
可以作为GC Root的对象
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(即一般说的Native方法)引用的对象
五种引用:
强引用(只有GC Root都不引用该对象时,才会回收强引用对象)
软引用(当GC Root指向软引用对象时,在内存不足时,会回收软引用所引用的对象)
弱引用(只有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用所引用的对象)
虚引用(当虚引用对象所引用的对象被回收以后,虚引用对象就会被放入引用队列中,调用虚引用的方法)
虚引用的一个体现是释放直接内存所分配的内存,当引用的对象ByteBuffer被垃圾回收以后,虚引用对象Cleaner就会被放入引用队列中,然后调用Cleaner的clean方法来释放直接内存
终结器引用(所有的类都继承自Object类,Object类有一个finalize方法。当某个对象不再被其他的对象所引用时,会先将终结器引用对象放入引用队列中,然后根据终结器引用对象找到它所引用的对象,然后调用该对象的finalize方法。调用以后,该对象就可以被垃圾回收了)
终结器引用有可能造成终结器对象指向的对象迟迟不被回收,因为该对象只有finallize方法被调用,才会被回收,而调用fianlly对象的线程优先度很低,不建议使用。
引用队列
软引用和弱引用可以配合引用队列(在弱引用和虚引用所引用的对象被回收以后,会将这些引用放入引用队列中,方便一起回收这些软/弱引用对象)
虚引用和终结器引用必须配合引用队列(虚引用和终结器引用在使用时会关联一个引用队列)
垃圾回收算法:
标记-清除法(在虚拟机执行垃圾回收的过程中,先采用标记算法确定可回收对象,然后垃圾收集器根据标识清除相应的内容,给堆内存腾出相应的空间)
(优点:速度快;缺点:容易产生内存碎片)
标记-整理法(优点:没什么内存碎片;缺点:移动时效率低,速度慢)
复制法(优点:没什么内存碎片;缺点:双倍的内存空间)
分代回收
jvm将堆内存分为新生代
和老年代
新生代的幸存区用的是复制算法,老年代用的是标记-整理算法
GC分析:
大对象处理策略(当遇到一个较大的对象时,就算新生代的伊甸园为空,也无法容纳该对象时,会将该对象直接晋升为老年代)
线程内存溢出(某个线程的内存溢出了而抛异常(out of memory),不会让其他的线程结束运行:这是因为当一个线程抛出OOM异常后,它所占据的内存资源会全部被释放掉,从而不会影响其他线程的运行,进程依然正常)
垃圾回收相关参数
含义 参数
堆初始大小 -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
垃圾回收器
并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上
吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%
串行
- 单线程
- 内存较小,个人电脑(CPU核数较少)
安全点:让其他线程都在这个点停下来,以免垃圾回收时移动对象地址,使得其他线程找不到被移动的对象
因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态
eg:
Serial收集器是最基本的、发展历史最悠久的收集器(单线程、简单高效(与其他收集器的单线程相比),采用复制算法。)(对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程手机效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World))
ParNew收集器其实就是Serial收集器的多线程版本(ParNew收集器默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。和Serial收集器一样存在Stop The World问题)
Serial Old是Serial收集器的老年代版本(同样是单线程收集器,采用标记-整理算法)
吞吐量优先
- 多线程
- 堆内存较大,多核CPU
- 单位时间内,STW(stop the world,停掉其他所有工作线程)时间最短
- JDK1.8默认使用的垃圾回收器
eg:
Parallel Old 收集器(是Parallel Scavenge收集器的老年代版本)(多线程,采用标记-整理算法(老年代没有幸存区))
Parallel Scavenge 收集器(与吞吐量关系密切,故也称为吞吐量优先收集器)(属于新生代收集器也是采用复制算法的收集器(用到了新生代的幸存区),又是并行的多线程收集器(与ParNew收集器类似))
该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC自适应调节策略(与ParNew收集器最重要的一个区别)
GC自适应调节策略:Parallel Scavenge收集器可设置-XX:+UseAdptiveSizePolicy参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为GC的自适应调节策略。
Parallel Scavenge收集器使用两个参数控制吞吐量:
- XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间
- XX:GCRatio 直接设置吞吐量的大小
响应时间优先
- 多线程
- 堆内存较大,多核CPU
- 尽可能让单次STW时间变短(尽量不影响其他线程运行)
eg:CMS 收集器(Concurrent Mark Sweep,一种以获取最短回收停顿时间为目标的老年代收集器)
(基于标记-清除算法实现。并发收集、低停顿,但是会产生内存碎片)
(应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如web程序、b/s服务)
CMS收集器的运行过程分为下列4步:
初始标记:标记GC Roots能直接到的对象。速度很快但是仍存在Stop The World问题
并发标记:进行GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行
重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在Stop The World问题
并发清除:对标记的对象进行清除回收
CMS收集器的内存回收过程是与用户线程一起并发执行的
G1垃圾回收器
Garbage First,JDK 9以后默认使用,而且替代了CMS 收集器
回收阶段:新生代伊甸园垃圾回收—–>内存不足,新生代回收+并发标记—–>回收新生代伊甸园、幸存区、老年代内存——>新生代伊甸园垃圾回收(重新开始)
Young Collection
分区算法region
分代是按对象的生命周期划分,分区则是将堆空间划分连续几个不同小区间,每一个小区间独立回收,可以控制一次回收多少个小区间,方便控制 GC 产生的停顿时间
E:伊甸园 S:幸存区 O:老年代
- 会STW
伊甸园到幸存者区到老年代的分区图示
Young Collection + CM
CM:并发标记
- 在 Young GC 时会对 GC Root 进行初始标记
- 在老年代占用堆内存的比例达到阈值时,对进行并发标记(不会STW),阈值可以根据用户来进行设定
Mixed Collection
会对E S O 进行全面的回收
- 最终标记
- 拷贝存活
-XX:MaxGCPauseMills:xxx 用于指定最长的停顿时间
问:为什么有的老年代被拷贝了,有的没拷贝?
因为指定了最大停顿时间,如果对所有老年代都进行回收,耗时可能过高。为了保证时间不超过设定的停顿时间,会回收最有价值的老年代(回收后,能够得到更多内存)
跨代引用
新生代的跨代引用:当我们要对新生代进行垃圾回收时,我们要寻找新生代的根对象,而新生代的根对象往往在老年代里,而遍历老年代所有对象又费时费力。
所以,我们采用卡表机制,将老年代对象细分,分为很多卡,当有引用了新生代对象,就把对应的卡标记为脏卡,这样,就只用关注脏卡区域就行了。
remark
重新标记阶段
在垃圾回收时,收集器处理对象的过程中
黑色:已被处理,需要保留的 灰色:正在处理中的 白色:还未处理的
remark的问题阐述:首先,明确一点概念,垃圾回收器处理对象只会逐个处理,已经处理过的路径,不会再对其重复处理。
于是就存在一个问题,某一c对象被b对象取消引用,同时并发标记时,用户线程调用了已经处理过的a对象又对c进行引用。而此时c仍被标记为清理对象,而a又不会被垃圾回收。
之后c会因为被标记为无引用被回收掉(实际上被引用),而引起损失。
于是,垃圾回收器有一个remark机制来处理这个问题,当对象引用改变时,会对他进行一个写屏障,然后stm,将它放进处理队列里,重新判断,标记它的引用。
JDK 8u20字符串去重
过程:
-
将所有新分配的字符串(底层是char[])放入一个队列
-
当新生代回收时,G1并发检查是否有重复的字符串
-
如果字符串的值一样,就让他们引用同一个字符串对象
-
注意,其与String.intern的区别
- intern关注的是字符串对象
- 字符串去重关注的是char[]
- 在JVM内部,使用了不同的字符串标
优点与缺点:
- 节省了大量内存
- 新生代回收时间略微增加,导致略微多占用CPU
JDK 8u40 并发标记类卸载
在并发标记阶段结束以后,就能知道哪些类不再被使用。如果一个类加载器的所有类都不在使用,则卸载它所加载的所有类
GC 调优(了解即可)
"E:\JAVA\JDK8.0\bin\java" -XX:+PrintFlagsFinal -version | findstr "GC" // 查看虚拟机参数命令
调优领域
- 内存
- 锁竞争
- CPU占用
- IO
- GC
确定目标:低延迟/高吞吐量? 选择合适的GC
- CMS G1 ZGC
- ParallelGC
- Zing
最快的GC是不发生GC
首先排除减少因为自身编写的代码而引发的内存问题
- 查看Full GC前后的内存占用,考虑以下几个问题
- 数据是不是太多?
- 数据表示是否太臃肿
- 对象图
- 对象大小
- 是否存在内存泄漏
新生代调优
- 新生代的特点
- 所有的new操作分配内存都是非常廉价的
- TLAB
- 死亡对象回收零代价
- 大部分对象用过即死(朝生夕死)
- MInor GC 所用时间远小于Full GC
- 所有的new操作分配内存都是非常廉价的
新生代内存越大越好么?
不是,新生代内存设置为内容纳[并发量*(请求-响应)]的数据为宜
- 新生代内存太小:频繁触发Minor GC,会STW,会使得吞吐量下降
- 新生代内存太大:老年代内存占比有所降低,会更频繁地触发Full GC。而且触发Minor GC时,清理新生代所花费的时间会更长
幸存区调优
- 幸存区需要能够保存 当前活跃对象+需要晋升的对象
- 晋升阈值配置得当,让长时间存活的对象尽快晋升
7.字节码
从更深层的角度去理解类是怎么被加载的
字节码文件的分析:利用javac获得字节码文件,并结合官方文档分析
7.1 反编译的到的字节码文件(javap)的分析
java提供了一种更简单易懂的字节码阅读方式,通过javap命令进行反编译
javap -v E:\Thread_study\src\com\cui\JVM\day01\Main.class
常量载入运行时常量池
一个常见的易错问题(x=0问题)
public class Demo2 {
public static void main(String[] args) {
int i=0;
int x=0;
while(i<10) {
x = x++;
i++;
}
System.out.println(x); //接过为0
}
}
为什么会这样呢?
从字节码中寻找原因:
Code:
stack=2, locals=3, args_size=1 //操作数栈分配2个空间,局部变量表分配3个空间
0: iconst_0 //准备一个常数0
1: istore_1 //将常数0放入局部变量表的1号槽位 i=0
2: iconst_0 //准备一个常数0
3: istore_2 //将常数0放入局部变量的2号槽位 x=0
4: iload_1 //将局部变量表1号槽位的数放入操作数栈中
5: bipush 10 //将数字10放入操作数栈中,此时操作数栈中有2个数
7: if_icmpge 21 //比较操作数栈中的两个数,如果下面的数大于上面的数,就跳转到21。这里的比较是将两个数做减法。因为涉及运算操作,所以会将 两个数弹出操作数栈来进行运算。运算结束后操作数栈为空
10: iload_2 //将局部变量2号槽位的数放入操作数栈中,放入的值是0
11: iinc 2, 1 //将局部变量2号槽位的数加1,自增后,槽位中的值为1
14: istore_2 //将操作数栈中的数放入到局部变量表的2号槽位,2号槽位的值又变为了0
15: iinc 1, 1 //1号槽位的值自增1
18: goto 4 //跳转到第4条指令
21: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
24: iload_2
25: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
28: return
其实就是一个顺序问题,x=x++;
这行代码正确的运行顺序是:
- 先把
x=0
放到操作数栈, - 之后
x++(innc)
此时局部变量表里x=1 - 之后才进行赋值操作,用操作数栈里的
x=0
替换掉了局部变量表里的x
.
所以无论多少次 x都等于0。
7.2 构造方法
cinit()V
public class Demo3 {
static int i = 10;
static {
i = 20;
}
static {
i = 30;
}
public static void main(String[] args) {
System.out.println(i); //结果为30
}
}
编译器会按从上至下的顺序,收集所有 static 静态代码块和静态成员赋值的代码,合并为一个特殊的方法 cinit()V
stack=1, locals=0, args_size=0
0: bipush 10
2: putstatic #3 // Field i:I
5: bipush 20
7: putstatic #3 // Field i:I
10: bipush 30
12: putstatic #3 // Field i:I
15: return
init()V
public class Demo4 {
private String a = "s1";
{
b = 20;
}
private int b = 10;
{
a = "s2";
}
public Demo4(String a, int b) {
this.a = a;
this.b = b;
}
public static void main(String[] args) {
Demo4 d = new Demo4("s3", 30);
System.out.println(d.a);
System.out.println(d.b);
}
}
编译器会按从上至下的顺序,收集所有 {} 代码块和成员变量赋值的代码,形成新的构造方法,但原始构造方法内的代码总是在后
Code:
stack=2, locals=3, args_size=3
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: ldc #2 // String s1
7: putfield #3 // Field a:Ljava/lang/String;
10: aload_0
11: bipush 20
13: putfield #4 // Field b:I
16: aload_0
17: bipush 10
19: putfield #4 // Field b:I
22: aload_0
23: ldc #5 // String s2
25: putfield #3 // Field a:Ljava/lang/String;
//原始构造方法在最后执行
28: aload_0
29: aload_1
30: putfield #3 // Field a:Ljava/lang/String;
33: aload_0
34: iload_2
35: putfield #4 // Field b:I
38: return
7.3 方法调用
不同方法在调用时,对应的虚拟机指令有所区别
- 私有、构造、被final修饰的方法,在调用时都使用invokespecial指令
- 普通成员方法在调用时,使用invokespecial指令。因为编译期间无法确定该方法的内容,只有在运行期间才能确定
- 静态方法在调用时使用invokestatic指令
- new 是创建【对象】,给对象分配堆内存,执行成功会将【对象引用】压入操作数栈
- dup 是赋值操作数栈栈顶的内容,本例即为【对象引用】,为什么需要两份引用呢,一个是要配合 invokespecial 调用该对象的构造方法 “init”()V (会消耗掉栈顶一个引用),另一个要 配合 astore_1 赋值给局部变量
- 终方法(final),私有方法(private),构造方法都是由 invokespecial 指令来调用,属于静态绑定
- 普通成员方法是由 invokevirtual 调用,属于动态绑定,即支持多态 成员方法与静态方法调用的另一个区别是,执行方法前是否需要【对象引用】
多态原理
因为普通成员方法需要在运行时才能确定具体的内容,所以虚拟机需要调用invokevirtual指令
在执行invokevirtual指令时,经历了以下几个步骤
- 先通过栈帧中对象的引用找到对象
- 分析对象头,找到对象实际的Class
- Class结构中有vtable
- 查询vtable找到方法的具体地址
- 执行方法的字节码
7.4 异常处理
try-catch
public class Demo1 {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
}catch (Exception e) {
i = 20;
}
}
}
Code:
stack=1, locals=3, args_size=1
0: iconst_0
1: istore_1
2: bipush 10
4: istore_1
5: goto 12
8: astore_2
9: bipush 20
11: istore_1
12: return
//多出来一个异常表
Exception table:
from to target type
2 5 8 Class java/lang/Exception
- 可以看到多出来一个 Exception table 的结构,[from, to) 是前闭后开(也就是检测2~4行)的检测范围,一旦这个范围内的字节码执行出现异常,则通过 type 匹配异常类型,如果一致,进入 target 所指示行号
- 8行的字节码指令 astore_2 是将异常对象引用存入局部变量表的2号位置(为e)
多个single-catch
public class Demo1 {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
}catch (ArithmeticException e) {
i = 20;
}catch (Exception e) {
i = 30;
}
}
Code:
stack=1, locals=3, args_size=1
0: iconst_0
1: istore_1
2: bipush 10
4: istore_1
5: goto 19
8: astore_2
9: bipush 20
11: istore_1
12: goto 19
15: astore_2
16: bipush 30
18: istore_1
19: return
Exception table:
from to target type
2 5 8 Class java/lang/ArithmeticException
2 5 15 Class java/lang/Exception
因为异常出现时,只能进入 Exception table 中一个分支,所以局部变量表 slot 2 位置被共用
finally
public class Demo2 {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (Exception e) {
i = 20;
} finally {
i = 30;
}
}
}
Code:
stack=1, locals=4, args_size=1
0: iconst_0
1: istore_1
//try块
2: bipush 10
4: istore_1
//try块执行完后,会执行finally
5: bipush 30
7: istore_1
8: goto 27
//catch块
11: astore_2 //异常信息放入局部变量表的2号槽位
12: bipush 20
14: istore_1
//catch块执行完后,会执行finally
15: bipush 30
17: istore_1
18: goto 27
//出现异常,但未被Exception捕获,会抛出其他异常,这时也需要执行finally块中的代码
21: astore_3
22: bipush 30
24: istore_1
25: aload_3
26: athrow //抛出异常
27: return
Exception table:
from to target type
2 5 11 Class java/lang/Exception
2 5 21 any
11 15 21 any
可以看到 finally 中的代码被复制了 3 份,分别放入 try 流程,catch 流程以及 catch剩余的异常类型流程
虽然从字节码指令看来,每个块中都有finally块,但是finally块中的代码只会被执行一次
finally中的return(会吞掉异常!!!)
public class Demo3 {
public static void main(String[] args) {
int i = Demo3.test();
//结果为20
System.out.println(i);
}
public static int test() {
int i;
try {
i = 10;
return i;
} finally {
i = 20;
return i;
}
}
}
Code:
stack=1, locals=3, args_size=0
0: bipush 10
2: istore_0
3: iload_0
4: istore_1 //暂存返回值
5: bipush 20
7: istore_0
8: iload_0
9: ireturn //ireturn会返回操作数栈顶的整型值20
//如果出现异常,还是会执行finally块中的内容,没有抛出异常
10: astore_2
11: bipush 20
13: istore_0
14: iload_0
15: ireturn //这里没有athrow了,也就是如果在finally块中如果有返回操作的话,且try块中出现异常,会吞掉异常!
Exception table:
from to target type
0 5 10 any
-
由于 finally 中的 ireturn 被插入了所有可能的流程,因此返回结果肯定以finally的为准
-
至于字节码中第 2 行,似乎没啥用,且留个伏笔,看下个例子
-
跟上例中的 finally 相比,发现没有 athrow 了,这告诉我们:如果在 finally 中出现了 return,会吞掉异常
-
所以不要在finally中进行返回操作!
finally不带return
public class Demo4 {
public static void main(String[] args) {
int i = Demo4.test();
System.out.println(i);
}
public static int test() {
int i = 10;
try {
return i;
} finally {
i = 20;
}
}
}
Code:
stack=1, locals=3, args_size=0
0: bipush 10
2: istore_0 //赋值给i 10
3: iload_0 //加载到操作数栈顶
4: istore_1 //加载到局部变量表的1号位置
5: bipush 20
7: istore_0 //赋值给i 20
8: iload_1 //加载局部变量表1号位置的数10到操作数栈
9: ireturn //返回操作数栈顶元素 10
10: astore_2
11: bipush 20
13: istore_0
14: aload_2 //加载异常
15: athrow //抛出异常
Exception table:
from to target type
3 5 10 any
Synchronized
public class Demo5 {
public static void main(String[] args) {
int i = 10;
Lock lock = new Lock();
synchronized (lock) {
System.out.println(i);
}
}
}
class Lock{}
Code:
stack=2, locals=5, args_size=1
0: bipush 10
2: istore_1
3: new #2 // class com/nyima/JVM/day06/Lock
6: dup //复制一份,放到操作数栈顶,用于构造函数消耗
7: invokespecial #3 // Method com/nyima/JVM/day06/Lock."<init>":()V
10: astore_2 //剩下的一份放到局部变量表的2号位置
11: aload_2 //加载到操作数栈
12: dup //复制一份,放到操作数栈,用于加锁时消耗
13: astore_3 //将操作数栈顶元素弹出,暂存到局部变量表的三号槽位。这时操作数栈中有一份对象的引用
14: monitorenter //加锁
//锁住后代码块中的操作
15: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
18: iload_1
19: invokevirtual #5 // Method java/io/PrintStream.println:(I)V
//加载局部变量表中三号槽位对象的引用,用于解锁
22: aload_3
23: monitorexit //解锁
24: goto 34
//异常操作
27: astore 4
29: aload_3
30: monitorexit //解锁
31: aload 4
33: athrow
34: return
//可以看出,无论何时出现异常,都会跳转到27行,将异常放入局部变量中,并进行解锁操作,然后加载异常并抛出异常。
Exception table:
from to target type
15 24 27 any
27 31 27 any
7.5 编译期处理(语法糖)
所谓的 语法糖 ,其实就是指 java 编译器把 *.java 源码编译为 *.class 字节码的过程中,自动生成和转换的一些代码,主要是为了减轻程序员的负担,算是 java 编译器给我们的一个额外福利
注意,以下代码的分析,借助了 javap 工具,idea 的反编译功能,idea 插件 jclasslib 等工具。
另外, 编译器转换的结果直接就是 class 字节码,只是为了便于阅读,给出了 几乎等价 的 java 源码方式,并不是编译器还会转换出中间的 java 源码,切记。
默认构造函数:
public class Candy1 {
}
经过编译期优化后:
public class Candy1 {
//这个无参构造器是java编译器帮我们加上的
public Candy1() {
//即调用父类 Object 的无参构造方法,即调用 java/lang/Object." <init>":()V
super();
}
}
自动拆装箱
基本类型和其包装类型的相互转换过程,称为拆装箱
在JDK 5以后,它们的转换可以在编译期自动完成
public class Demo2 {
public static void main(String[] args) {
//基本类型赋值给包装类型,称为装箱
Integer x = Integer.valueOf(1);
//包装类型赋值给基本类型,称谓拆箱
int y = x.intValue();
}
}
泛型集合取值
泛型也是在 JDK 5 开始加入的特性,但 java 在编译泛型代码后会执行 泛型擦除 的动作,即泛型信息在编译为字节码之后就丢失了,实际的类型都当做了 Object 类型来处理:
public class Demo3 {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(10);
Integer x = list.get(0);
}
}
Code:
stack=2, locals=3, args_size=1
0: new #2 // class java/util/ArrayList
3: dup
4: invokespecial #3 // Method java/util/ArrayList."<init>":()V
7: astore_1
8: aload_1
9: bipush 10
11: invokestatic #4 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
//这里进行了泛型擦除,实际调用的是add(Objcet o)
14: invokeinterface #5, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
19: pop
20: aload_1
21: iconst_0
//这里也进行了泛型擦除,实际调用的是get(Object o)
22: invokeinterface #6, 2 // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
//这里进行了类型转换,将Object转换成了Integer
27: checkcast #7 // class java/lang/Integer
30: astore_2
31: return
类型转换:
//所以调用get函数取值时,有一个类型转换的操作:
Integer x = (Integer) list.get(0);
//如果要将返回结果赋值给一个int类型的变量,则还有自动拆箱的操作
int x = (Integer) list.get(0).intValue();
可变参数:
可变参数 String… args 其实是一个 String[] args ,从代码中的赋值语句中即可以看出。 java 编译器会在编译期间将发生代码变换:
public class Demo4 {
public static void foo(String... args) {
//将args赋值给arr,可以看出String...实际就是String[]
String[] arr = args;
System.out.println(arr.length);
}
public static void main(String[] args) {
foo("hello", "world");
}
}
public class Demo4 {
public Demo4 {}
public static void foo(String[] args) {
String[] arr = args;
System.out.println(arr.length);
}
public static void main(String[] args) {
foo(new String[]{"hello", "world"});
}
}
如果调用的是foo(),即未传递参数时,等价代码为foo(new String[]{}),创建了一个空数组,而不是直接传递的null
集合使用foreach
public class Demo5 {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
for (Integer x : list) {
System.out.println(x);
}
}
}
public class Demo5 {
public Demo5 {}
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
//获得该集合的迭代器
Iterator<Integer> iterator = list.iterator();
while(iterator.hasNext()) {
Integer x = iterator.next();
System.out.println(x);
}
}
}
switch:
-
在编译期间,单个的switch被分为了两个
-
第一个用来匹配字符串,并给x赋值
-
字符串的匹配用到了字符串的hashCode,还用到了equals方法
-
使用hashCode是为了提高比较效率,使用equals是防止有hashCode冲突(如BM和C.)
-
-
第二个用来根据x的值来决定输出语句
-
public class Demo6 {
public static void main(String[] args) {
String str = "hello";
switch (str) {
case "hello" :
System.out.println("h");
break;
case "world" :
System.out.println("w");
break;
default:
break;
}
}
}
public class Demo6 {
public Demo6() {
}
public static void main(String[] args) {
String str = "hello";
int x = -1;
//通过字符串的hashCode+value来判断是否匹配
switch (str.hashCode()) {
//hello的hashCode
case 99162322 :
//再次比较,因为字符串的hashCode有可能相等
if(str.equals("hello")) {
x = 0;
}
break;
//world的hashCode
case 11331880 :
if(str.equals("world")) {
x = 1;
}
break;
default:
break;
}
//用第二个switch在进行输出判断
switch (x) {
case 0:
System.out.println("h");
break;
case 1:
System.out.println("w");
break;
default:
break;
}
}
}
switch枚举:
public class Demo7 {
public static void main(String[] args) {
SEX sex = SEX.MALE;
switch (sex) {
case MALE:
System.out.println("man");
break;
case FEMALE:
System.out.println("woman");
break;
default:
break;
}
}
}
enum SEX {
MALE, FEMALE;
}
public class Demo7 {
/**
* 定义一个合成类(仅 jvm 使用,对我们不可见)
* 用来映射枚举的 ordinal 与数组元素的关系
* 枚举的 ordinal 表示枚举对象的序号,从 0 开始
* 即 MALE 的 ordinal()=0,FEMALE 的 ordinal()=1
*/
static class $MAP {
//数组大小即为枚举元素个数,里面存放了case用于比较的数字
static int[] map = new int[2];
static {
//ordinal即枚举元素对应所在的位置,MALE为0,FEMALE为1
map[SEX.MALE.ordinal()] = 1;
map[SEX.FEMALE.ordinal()] = 2;
}
}
public static void main(String[] args) {
SEX sex = SEX.MALE;
//将对应位置枚举元素的值赋给x,用于case操作
int x = $MAP.map[sex.ordinal()];
switch (x) {
case 1:
System.out.println("man");
break;
case 2:
System.out.println("woman");
break;
default:
break;
}
}
}
enum SEX {
MALE, FEMALE;
}
枚举类:
enum SEX {
MALE, FEMALE;
}
public final class Sex extends Enum<Sex> {
//对应枚举类中的元素
public static final Sex MALE;
public static final Sex FEMALE;
private static final Sex[] $VALUES;
static {
//调用构造函数,传入枚举元素的值及ordinal
MALE = new Sex("MALE", 0);
FEMALE = new Sex("FEMALE", 1);
$VALUES = new Sex[]{MALE, FEMALE};
}
//调用父类中的方法
private Sex(String name, int ordinal) {
super(name, ordinal);
}
public static Sex[] values() {
return $VALUES.clone();
}
public static Sex valueOf(String name) {
return Enum.valueOf(Sex.class, name);
}
}
匿名内部类:
public class Demo8 {
public static void main(String[] args) {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("running...");
}
};
}
}
public class Demo8 {
public static void main(String[] args) {
//用额外创建的类来创建匿名内部类对象
Runnable runnable = new Demo8$1();
}
}
//创建了一个额外的类,实现了Runnable接口
final class Demo8$1 implements Runnable {
public Demo8$1() {}
@Override
public void run() {
System.out.println("running...");
}
}
8.类加载
8.1 过程
第一阶段:加载
将类的字节码载入方法区(1.8后为元空间,在本地内存中)中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 field 有:
- _java_mirror 即 java 的类镜像,例如对 String 来说,它的镜像类就是 String.class,作用是把 klass 暴露给 java 使用
- _super 即父类
- _fields 即成员变量
- _methods 即方法
- _constants 即常量池
- _class_loader 即类加载器
- _vtable 虚方法表
- _itable 接口方法
如果这个类还有父类没有加载,先加载父类
加载和链接可能是交替运行的instanceKlass保存在方法区。
JDK 8以后,方法区位于元空间中,而元空间又位于本地内存中_java_mirror则是保存在堆内存中InstanceKlass和*.class(JAVA镜像类)互相保存了对方的地址类的对象在对象头中保存了*.class的地址。让对象可以通过其找到方法区中instanceKlass,从而获取类的各种信息
第二阶段:链接
验证:验证类是否符合 JVM规范,安全性检查,(对字节码魔数以及格式进行检查)
准备:为 static 变量分配空间,设置默认值
制备常量池但不赋值,但下面特殊情况下也会为static赋值
- static变量在JDK 7以前是存储与instanceKlass末尾。但在JDK 7以后就存储在_java_mirror末尾了
- static变量在分配空间和赋值是在两个阶段完成的。分配空间在准备阶段完成,赋值在初始化阶段完成
- 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
- 如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成
解析:这个阶段会对常量池进行内存地址的匹配,此时才开始为常量分配空间,才真正存在于内存中。
第三阶段:初始化
类的初始化的懒惰的,以下情况会初始化
- main 方法所在的类,总会被首先初始化
- 首次访问这个类的静态变量或静态方法时
- 子类初始化,如果父类还没初始化,会引发
- 子类访问父类的静态变量,只会触发父类的初始化
- Class.forName
- new 会导致初始化
以下情况不会初始化
- 访问类的 static final 静态常量(基本类型和字符串)
- 类对象.class 不会触发初始化
- 创建该类对象的数组
- 类加载器的.loadClass方法
- Class.forNamed的参数2为false时
验证
public class Load3 {
static {
//在main类前面的静态代码块会优先初始化
System.out.println("main init");
}
public static void main(String[] args) throws ClassNotFoundException {
// 1. 静态常量(基本类型和字符串)不会触发初始化
System.out.println(B.b);
// 2. 类对象.class 不会触发初始化
System.out.println(B.class);
// 3. 创建该类的数组不会触发初始化
System.out.println(new B[0]);
// 4. 不会初始化类 B,但会加载 B、A
ClassLoader cl = Thread.currentThread().getContextClassLoader();
cl.loadClass("cn.itcast.jvm.t3.B");
// 5. 不会初始化类 B,但会加载 B、A
ClassLoader c2 = Thread.currentThread().getContextClassLoader();
Class.forName("cn.itcast.jvm.t3.B", false, c2);
// 1. 首次访问这个类的静态变量或静态方法时
System.out.println(A.a);
// 2. 子类初始化,如果父类还没初始化,会引发父类的初始化
System.out.println(B.c);
// 3. 子类访问父类静态变量,只触发父类初始化
System.out.println(B.a);
// 4. 会初始化类 B,并先初始化类 A
Class.forName("cn.itcast.jvm.t3.B");
}
}
class A {
static int a = 0;
static {
System.out.println("a init");
}
}
class B extends A {
final static double b = 5.0;
static boolean c = false;
static {
System.out.println("b init");
}
}
懒惰单例
public final class Singleton {
private Singleton() { }
// 内部类中保存单例
private static class LazyHolder { static final Singleton INSTANCE = new Singleton(); }
// 第一次调用 getInstance 方法,才会导致内部类加载和初始化其静态成员
public static Singleton getInstance() { return LazyHolder.INSTANCE; }
}
练习:
public class Load4 {
public static void main(String[] args) {
System.out.println(E.a);//不能初始化,final在准备阶段就已经赋值了
System.out.println(E.b);//不能初始化,final在准备阶段就已经赋值了
System.out.println(E.c); //能够初始化,因为封装和拆箱要涉及到初始化
} }
class E {
public static final int a = 10;
public static final String b = "hello";
public static final Integer c = 20;
}
8.2 类加载器
Java虚拟机设计团队有意把类加载阶段中的**“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”**(ClassLoader)
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等
以JDK 8为例:
名称 | 加载的类 | 说明 |
---|---|---|
Bootstrap ClassLoader(启动类加载器) | JAVA_HOME/jre/lib | 无法直接访问 |
Extension ClassLoader(拓展类加载器) | JAVA_HOME/jre/lib/ext | 上级为Bootstrap,显示为null |
Application ClassLoader(应用程序类加载器) | classpath | 上级为Extension |
自定义类加载器 | 自定义 | 上级为Application |
启动类加载器
可通过在控制台输入指令,使得类被启动类加器加载
因为bootstrap加载器加载时部分需要使用c++的代码帮忙运行,而没办法把类加载器名称及地址正确返回,返回null就代表了时启动类加载器。
拓展类加载器
如果classpath和JAVA_HOME/jre/lib/ext 下有同名类,加载时会使用拓展类加载器加载。当应用程序类加载器发现拓展类加载器已将该同名类加载过了,则不会再次加载
双亲委派机制
双亲委派机制,指的是类加载时会向上级加载器报备,优先上级加载器加载的一种模式。
public class Load5_3 {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> aClass = Load5_3.class.getClassLoader() .loadClass("cn.itcast.jvm.t3.load.H"); System.out.println(aClass.getClassLoader());
} }
不使用双亲委派机制的特例–线程上下文类加载器
我们在使用 JDBC 时,都需要加载 Driver 驱动,不知道你注意到没有,不写loadClass
自定义类加载器
使用场景
- 想加载非 classpath 随意路径中的类文件
- 通过接口来使用实现,希望解耦时,常用在框架设计
- 这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器
步骤
- 继承ClassLoader父类
- 要遵从双亲委派机制,重写 findClass 方法
- 不是重写loadClass方法,否则不会走双亲委派机制
- 读取类文件的字节码
- 调用父类的 defineClass 方法来加载类
- 使用者调用该类加载器的 loadClass 方法
例子:
8.3 运行期优化
分层编译
JVM 将执行状态分成了 5 个层次:
- 0层:解释执行,用解释器将字节码翻译为机器码
- 1层:使用 C1 即时编译器编译执行(不带 profiling)
- 2层:使用 C1 即时编译器编译执行(带基本的profiling)
- 3层:使用 C1 即时编译器编译执行(带完全的profiling)
- 4层:使用 C2 即时编译器编译执行
profiling 是指在运行过程中收集一些程序执行状态的数据,例如【方法的调用次数】,【循环的 回边次数】等
即时编译器(JIT)just in time与解释器的区别
- 解释器
- 将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
- 是将字节码解释为针对所有平台都通用的机器码
- 即时编译器
- 将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
- 根据平台类型,生成平台特定的机器码
就是执行次数多的代码保留机械码,多使用:
对于大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;
另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。
执行效率上简单比较一下 Interpreter < C1 < C2,总的目标是发现热点代码(hotspot名称的由 来),并优化这些热点代码