零、前言
0.1定义
0.2常见JVM
下面讲解的都是根据HotSpot来讲的。
一个类从源代码编译为class文件,通过类加载器加载到JVM中,类放在方法区,创建的对象放在堆中;调用方法时使用到栈、程序计数器、本地方法栈。
方法执行时,通过执行引擎中的解释器逐行执行,方法中的热点代码通过JIT优化执行,GC进行垃圾回收。通过本地方法接口调用操作系统的功能方法。
学习顺序:JVM内存结构-> 垃圾回收 -> 字节码结构 -> 类加载器 -> JIT
JIT:JVM先将字节码翻译为对应的机器指令,然后执行机器指令。很显然,这样经过解释执行,其执行速度必然不如直接执行二进制字节码文件。了提高执行速度,便引入了 JIT 技术。当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后JIT会把部分“热点代码”编译成本地机器相关的机器码,并进行优化,然后再把编译后的机器码缓存起来,以备下次使用。
一、内存结构
1.1程序计数器
1.1.1作用及特点
程序计数器就是用于记住下一条JVM指令的执行地址,当前指令执行完毕,解释器就会去计数器找下一条指令。
计数器的物理实现是通过寄存器实现的,这是CPU中最快的单元。
- 特点
- 线程私有
- 不会存在内存溢出
1.2虚拟机栈
1.2.1定义
栈——线程运行需要的内存空间。
栈帧——每个方法运行时需要的内存。栈帧中包括参数、局部变量、返回地址。
问题辨析:
1.垃圾回收是否涉及栈内存?不需要,栈内存由多个栈帧组成,每次方法执行完毕,栈帧就会被弹出,无需垃圾回收。
2.栈内存越大越好? 不是,栈内存越大,线程数目反而更少。
3.方法内的局部变量是否线程安全?如果方法内局部变量没有超过作用范文就是线程安全的;如果局部变量引用了对象并逃离方法的作用域,可能存在线程安全
如图,m1中sb不存在线程安全问题;m2中sb存在线程安全问题,因为有传入;m3中也存在线程安全问题,因为有返回。
1.2.1栈内存溢出
1.何时存在栈内存溢出?
- 栈帧过多导致栈内存溢出。
- 栈帧过大导致栈内存溢出。
2.如何设置栈内存大小?
- 参数:-Xss
- 点击idea的Debug Application,在里面的VM options写。如:-Xss256k
3.常见栈内存溢出场景
- 递归调用不当
- 三方库导致
1.2.2线程运行诊断
-
案例I:cpu占用过多
如下图,后台运行一段java代码,使用top命令查看cpu占用情况。
发现该代码占用了99.3%,占用过多!通过top命令只能看出占用过多cpu的进程编号32655
但是看不到哪个线程导致的。可以使用ps用于查看进程中的线程,其中,H参数打印进程数,eo表示展示的信息。pid进程id tid线程id %cpu占用cpu情况
jstack 进程id,这是jdk的命令,可以列出这个进程中所有的java线程。
在其中找到对应线程号的线程(注意:ps展示的是十进制的,而jstack展示的是16进制的),32665->7F99
如上图,所以是thread1这个java线程导致cpu资源过多!出现问题的代码行数是第八行。 -
案例II:程序运行长时间无结果
运行代码长时间无响应:
使用jstack 进程id,查看线程信息,观察最后输出:
上图意思是to,t1发生死锁,继续往下看详细信息:
死锁发生在t1的29行,t0的21行。t1在等待释放a对象的锁,自己锁住了对象b;t0在等待释放b对象上的锁,自己锁住了对象a!
1.3本地方法栈
虚拟机调用本地方法时给本地方法分配的空间,本地方法指的是不是用java写的用于和操作系统交互的代码。
1.4堆
1.4.1堆内存溢出
原因:list一直在作用范围内(try块内),无法被回收;字符串对象也一直无法被回收
如何调整堆内存大小?
- 参数-Xmx
1.4.2堆内存诊断
jmap只能看某时刻的,jconsole可以连续监测
eg1:
- jps看有哪些进程
- jmap看某时刻堆内存占用
堆配置信息:
堆内存占用:
其中Eden Space这个区域是新创建对象用的区域,capacity是总容量,used是已用的容量 - jconsole连续查看
eg2,垃圾回收后,内存占用任然很高:
1.查看进程jps
2.查看堆内存占用
发现总共约用了230Mb
3.测试gc后的效果
发现GC执行完还有两百多兆
4.再次jmap查看堆内存占用,发现老年代内存没gc
5.使用jvisualvm可视化展现虚拟机工具
这个arraylist占的很大,有很多student对象,一个student对象1M,所以应该是,arraylist里面的student无法回收
1.5方法区
1.5.1定义
方法区所有线程共享,存储了和类的结构相关的信息(运行时常量池、成员变量、方法数据、成员方法构造器方法的代码、特殊方法)
方法区在虚拟机启动时创建,逻辑上是堆的组成部分(具体实现上各家不一定)。这个规范不强制方法区的位置(如orcal的hotspot虚拟机8之前实现是永久栈就是堆的一部分;8之后是元空间,不再是堆的一部分而是系统的内存)。
方法区也存在内存溢出的可能:
1.5.2组成
1.6是永久代的实现(占用堆内存),内有运行时常量池、类加载器、类信息等。
1.8之后实现方式是原空间,不再占用堆内存,而占用本地内存。其中StringTable不再放在方法区的运行时常量池中了,而在对内存
1.5.3方法区内存溢出
元空间溢出演示
由于元空间实现方式使用的是系统内存,所以上述代码很难导致元空间内存溢出。
加一个虚拟机参数-XX:maxMetaspaceSize=8m 来演示内存溢出:
演示永久代内存溢出
使用虚拟机参数-XX: MaxPermSize=8m来演示永久代内存溢出:
这个错误和上面那个元空间内存溢出的错误是一种,但是:后面的提示不是一种:
方法区内存溢出的场景
- spring、mybatis这些框架运用到了cglab动态代理技术,spring中用来生成代理类;mybais中用来产生mapper接口实现类
会在运行期间动态生成类的字节码,完成动态的类加载。可能导致方法区内存溢出。
1.5.3运行时常量池
反编译该字节码
得到的结果有:
类基本信息:
常量池:
类的方法定义:
常量池的作用是给指令提供常量符号,让虚拟机来找到它,用于后续调用。
1.5.4StringTable
面试题1
- 当字符串型变量s1、s2拼接时:
先创建一个StringBuild对象,然后append(“a”),然后append(“b”),然后调用toString()方法,结果存放到s4中。
其中toString方法会创建一个新的字符串对象:
所以s3!=s4!!!
- 当字符串常量拼接时
String s5 = “a”+“b”;
会直接去常量池中创建"ab",由于已经存在,所以直接返回引用。
所以s3==s5
- intern主动将字符串对象写入串池
代码执行完后,会将动态拼接的"ab"写入串池(针对1.8,若是1.6版本,intern会将对象复制一份放入串池,而不是直接放入串池)
这里s==“ab”,s2==“ab”
如果代码这样写,s拼接好的对象是堆中的和串中的“ab"不一样;在intern那一步由于"ab"已经存在,所以不再创建,直接返回串池中的ab的引用(针对1.8,若是1.6版本,intern会将对象复制一份放入串池,而不是直接放入串池)
这里s!=“ab”,s2==“ab”
StringTable特性
所以:
StringTable位置
StringTable垃圾回收机制
字符串常量池并不是一成不变的,也会受到gc管理!
StringTable性能调优
1.调整 -XX:StringTableSize=桶个数
StringTable的底层是哈希表。
可以通过添加选项来调整bucket的大小,也就是哈希表的桶的个数。这个参数调的好,哈希冲突就少,少了的话那运行时间就会快。
-XX:+PrintStringTableStatistics 打印StringTable信息
-XX:StringTableSize=200000 调整桶大小
如果字符串常量的个数非常多,那么把桶的大小调高一些。
2.对于要用到的字符串对象有大量重复的情况,考虑将字符串对象入池 节省堆内存的使用(放入串池就共享字符串啦)
while (true) {
line = reader.readLine();
if (line == null) {
break;
}
line.intern(); //将字符串加入字符串常量池 可以节省内存。先入池,然后再添加到集合中去
}
通过VisualVM的抽样器查看
不入池:
入池:
1.6直接内存
1.6.1特点
- 1.直接内存不属于虚拟机,而是系统的内存
- 常见于NIO操作时用作缓冲区(NIO 是一种同步非阻塞的 IO 模型。同步是指线程不断轮询 IO 事件是否就绪,非阻塞是指线程在等待 IO 的时候,可以同时做其他任务)
- 分配回收成本较高,但读写性能高
- 不受JVM内存回收管理(属于os内存)
- 2.为甚么对大文件读写性能高
java自己没有磁盘调用的能力,必须调用系统内核的方法,cpu从用户态->内存态
当用户态->内核态,可以读取磁盘文件到系统缓冲区
当再次内核态->用户态,才能从系统缓冲区到java缓冲数组
因为存在两个缓冲区,造成不必要的数据复制,效率不是很高
经过上行代码,划分了一个direct Memory,这个direct Memory两个区域共享,所以少了一次不必要的数值复制。
1.6.2直接内存溢出
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
System.out.println("分配完毕...");
System.in.read();
System.out.println("开始释放...");
byteBuffer = null;
System.gc();
System.in.read();
在控制台随便输入回车,进入开始释放语句之后的代码,这一个G就被回收了。刚刚明明讲这个内存不由虚拟机管理,咋被回收了?
直接内存的内存释放是通过一个Unsafe对象管理的
byteBuffer对象被回收,触发了Unsafe类的回收直接内存的方法(底层用到了线程)
-XX:+DisableExplicitGC 禁用显示的垃圾回收
加上这个虚拟机参数可能会导致直接内存无法回收
二、垃圾回收
2.1怎样判断对象可以回收
2.1.1引用计数法
当引用计数为零,作为垃圾回收。
弊端:存在循环引用的问题:
无法被回收。
2.1.2可达性分析算法(java虚拟机采用的方法)
根对象:肯定不能被垃圾回收的算法。
使用MAT(Memory Analyzer)工具来分析:
- 使用jps查看进程id
- 使用jmap查看内存快照,并转储为一个文件: jmap -dump:format=b(表示文件格式),live(只关心存活对象,并主动触发一次回收),file=1.bin (存放文件) 进程id
- 打开MAT,选择储存的快照文件
观察到将跟对象分成了四类:第一类是系统类 (由启动类加载器加载)、第二类是操作系统方法引用的java对象、第三类是活动线程栈帧内使用的对象、第四类是正加锁的对象
2.1.3四种引用
软引用场景:这里的list和softReference是强引用,而softReference和byte[]之间是软引用。
清理软引用:
弱引场景:
清理弱引用:
<1>强引用
所有根对象对该对象的所有强引用都断开的时候,该对象才会被垃圾回收掉。
如A1
<2>软/弱引用
只要对象没有被直接的强引用所引用,就有可能被垃圾回收掉 ,
- 对于软引用,垃圾回收并且内存不够时被回收:
- 对于弱引用 ,不用关心内存是否充足都会被回收 。
- 当对象被回收时,对应的引用会进入引用队列
因为引用也占用一定内存,为了将内存释放,将他们放入引用队列,方便后序处理。
<3>虚引用
- 当虚引用被创建时会关联一个引用队列
比如虚引用引用一个ByteBuffer对象,这个对象关联一部分直接内存。当这个ByteBuffer对象被回收时,这个直接内存并没有被直接回收,所以创建虚引用的时候就将之关联到一个引用队列,当ByteBuffer对象被回收的时候将虚引用放进引用队列,一个xxx后台线程会在队列中找有没有新入队的Cleaner,根据Cleaner对象的Unsafe.freeMemory方法将直接内存释放掉。
<4>终结器引用
- 当强引用被创建时会关联一个引用队列
- 当终结器引用的对应重写了终结方法,并且无强引用指向它时,就可以被垃圾回收了。
当该对象被回收时,会将终结器引用加入到引用器队列中,由一个优先级较低的线程按时产看是否有终结器引用,若有则找到该对象并调用终结方法。
2.2垃圾回收算法
2.2.1标记清除算法
- 首先,先进行标记,沿着GC ROOT往下没有被引用的就是需要被标记的垃圾
- 清除,记录对象占用内存的起始和结束的地址,放在一个空闲的地址列表,下次存放新对象的时候回去看有没有足够的内存空间,有就分配
- 优点:速度快,只需要记录起始终止地址即可
- 缺点:容易产生内存碎片,有可能空闲的内存碎片都不足以放入新对象,但实际上空闲内存的总空间是够的,只是因为内存的不连续会造成内存碎片。
2.2.2标记整理算法
- 标记
- 整理
- 优点:不存在内存碎片的问题。
- 缺点:由于存在内存的移动,所以效率较低。
2.2.3复制算法
将内存区域划分为大小相等的两块区域:FROM、TO
将存活的对象复制到TO区域,清理FROM区域的垃圾,交换FROM和TO两块区域。
- 优点:没有内存碎片
- 缺点:占用双倍内存空间
2.2.4分代回收算法
为什么要有分代回收机制呢?
有的对象可能要长时间使用,放到老年代中。有些对象用完就扔,放到新生代中。这样可以根据对象的生命周期的特定进行不同的垃圾回收策略。
老年代的垃圾回收很久一次、新生代的垃圾回收比较频繁。
永久代指内存的永久保存区域,主要存放Class 和Meta (元数据)的信息,Class在被加载的时候被放入永久区域,它和和存放实例的区域不同,GC不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的Class 的增多而胀满,最终抛出OOM异常。自8之后不再使用永久代,转而放到元空间
工作机制:
- 创建新对象默认使用伊甸园空间
- 当空间不够存放新对象,触发一次新生代垃圾回收(Minor GC)
Minor GC执行,就会引发一次stop the world,即停止其他用户线程,由垃圾回收线程将垃圾拷贝到TO后其他线程才能够正常运行。(这是为了不影响对象复制)
会用可达性算法寻找存活的对象,使复制算法将存货的对象复制到幸村区TO中。寿命加1(最大寿命是15次),FROM和TO交换位置。
一段时间后伊甸园又满了,再次触发一次Minor GC。除了伊甸园还要去看幸存区的幸存对象,幸存的对象放入TO中寿命加一
再将垃圾回收,交换FROM和TO位置 - 当幸村区中的对象寿命超过了阈值,晋升在老年代
- 当新生代和老年代全满了,触发FULL GC
full gc的耗时更长。
相关虚拟机参数
![V![V![](https://img-blog.csdnimg.cn/ab0525ef8e814e19a15cf94a208383b7.png)
GC分析
-XX:+UseSerialGC这是一种垃圾回收器,jdk8下默认的垃圾回收器不是这个,所以修改为这个,这个垃圾回收器幸存区比例是固定的
堆初始大小20M 堆最大大小20M 新生代大小10 M 使用这个垃圾回收器 打印GC详情
-
新生代
看到总大小9m,而设置的参数明明是10M,这是因为8M给伊甸园、1MFrom、1MTo,而to对应的1M始终是不能用的,所以把它抛开了
used:使用了2.3M 伊甸园用了28% -
老年代
-
元空间
新生代GC:
其中DefNew是指发生在新生代(回收前->回收后(新生代总大小)),中括号外面是堆的占用前和占用后的大小。
大对象直接晋升老年代
-
插一个大对象(比如插入一个大于伊甸园容量的),就算gc也放不下,就会将大对象直接晋升到老年代中。
-
子线程内存溢出是否会导致主线程结束?不会!
2.3垃圾回收器
1.串行
- 单线程的垃圾回收器,
- 堆内存较小、个人电脑
2.吞吐量优先 - 多线程
- 堆内存较大,多核CPU
- 让单位时间内,STW的时间最短
3.响应时间优先 - 多线程
- 堆内存较大,多核CPU
- 尽可能让单次STW的时间更短
2.3.1串行
Serial工作在新生代,使用的算法是复制算法;SerialOld工作在老年代,使用的算法是标记整理算法
由于是单线程垃圾回收器,所以只有一个垃圾回收线程在运行,gc线程运行时,其他用户线程需要阻塞
2.3.2吞吐量优先
UseParallelGC工作在新生代,UseParalleOldGC工作在老年代。
这种方式有多个垃圾回收线程。线程个数默认和CPU核数相同。线程数量可以通过下参数控制:
下面参数表示采用自适应新生代大小调整策略:
2.3.3CMS
CMS会发生并发失败的问题,会退化成串行垃圾回收器
第二行参数用于设置并行GC线程数、一般与内核数相同。第二个是并发的gc线程数,一般是并行线程数的1/4
第三行参数是控制何时进行CMS垃圾回收
第四个重新标记时做一次垃圾回收,防止出现对垃圾的多余扫描
-
先初始标记,寻找标记根对象直接关联的对象。触发一次stop the world。时间短。
-
用户线程恢复线程,同时垃圾回收线程并发标记从关联对象到整个对象图(剩余垃圾)。时间长
-
重新标记标记用户线程工作而产生变动的对象,再次触发stop the world。比初始标记时间长,并发标记时间短。
-
用户线程恢复,并作一次并发清除,清除判断已死亡的对象。
-
缺点:用户线程不能使用全部内存(因为有垃圾回收线程并发),所以吞吐量下降
- 无法处理浮动垃圾(用户工作而产生的新垃圾),不能等到空间不足才GC,需要预留空间给浮动垃圾
- 基于标记清除算法,当空间碎片过多,很难分配大对象。失败时会退化为serialOld,时间会变很长。
2.3.4G1
第一个参数是开关(JDK9之后默认打开),第二个设置Region的大小,第三个是默认的暂停目标
- 同时注重吞吐量和低延迟,默认暂停目标是200ms,当想提高吞吐量时,可以调大些
- 适用于超大堆内存。和之前将堆分为固定的新生代…不同;会将堆分为一个个大小相等的region,每个region都可以独立作为伊甸园、幸存区、老年代。存在一类特殊的Humongous区域存储大对象。
- 整体是标记+整理算法,两个region之间是复制算法,所以没有内存碎片。
原理:
通过区域划分和优先级区域回收机制,确保G1收集器可以在有限时间获得最高的垃圾收集效率。
RememberedSet:
在串行和并行收集器中,GC时是通过整堆扫描来确定对象是否处于可达路径中。然而G1为了避免STW式的整堆扫描,为每个分区 (Region) 各自分配了一个 RSet(Remembered Set),它内部类似于一个反向指针,记录了其它 Region 对当前 Region 的引用情况,这样就带来一个极大的好处:回收某个Region时,不需要执行全堆扫描,只需扫描它的 RSet 就可以找到外部引用,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况,而这些引用就是 initial mark 的根之一。
CardTable:
一个 Card Table 将一个 Region 在逻辑上划分为若干个固定大小(介于128到512字节之间)的连续区域,每个区域称之为卡片 Card.
G1三个回收阶段
<1>阶段I:Young Collection
新生代放满,触发一次stop the world,将存活对象以拷贝算法放入幸存区
当幸存区对象过多,触发垃圾回收,部分对象(年龄达标)晋升老年区,部分对象再次拷贝另一个幸存区
<2>阶段II:Young Collection+Concurrent Mark
2.1初始标记
找到根对象,在Young GC时就会触发。
2.2并发标记
老年代占用空间达到一定比例时触发,不会STW,由下参数决定
老年代占比达到阈值,触发并发标记。从根对象开始进行可达性分析,扫描整个堆中的对象图,找出要回收的对象。
<3>阶段III:Mixed Collection
- 最终标记(stw)
处理并发结束后遗留的 - 筛选回收(stw)
对三个区进行全面的垃圾回收。
除了回收新生代对象,有一部分老年代对象也会回收。这个是根据停顿时间有选择(价值最大的regions)的进行垃圾回收。待回收region中仍存活的对象经过复制算法复制到新的老年代区,将旧的老年代回收。
关于筛选回收
FULL GC辨析
前两种当老年代内存不足时就会触发FULL GC,后两者未必。
CMS当并发失败时会退化为FULL GC,否则就还是并发清理。
G1
Young Collection 跨代引用
如图,老年代一个对象引用了新生代的对象称之为脏卡。将来不用找整个老年代,而是只需要关注那些脏卡区域即可,减少搜索范围,提高效果。
新生代会有一个rememberedSet记录外部对其的引用,将来对新生代垃圾回收可以通过rememberedSet得到对应脏卡,对应脏卡遍历GCROOT减少遍历时间。
标记脏卡通过写屏障在每次引用变更时,更新脏卡,这是异步操作;将指令放在脏卡队列中,将来由一个线程完成脏卡更新操作。
三色标记算法
三色标记算法是并发收集阶段的重要算法,它是描述追踪式回收器的一种有用的方法,利用它可以推演回收器的正确性。 首先,我们将对象分成三种类型的。
- 正常情况
- 异常情况
如果在标记过程中,应用程序也在运行,那么对象的指针就有可能改变。这样的话,我们就会遇到一个问题:对象丢失问题。我们看下面一种情况,当垃圾收集器扫描到下面情况时
经过A.c=C;B.c=null;
eg:当C受到改变的时候,加了一个写屏障,并且加入到一个队列并变为灰色。
整个并发标记结束,stw。出队再次检查发现有强引用引用C,所以将C变为黑色
<7>字符串去重
<8>并发标记类卸载
条件是类和类所在的类加载器的所有类都不再使用
<9>回收巨型对象
若老年代卡表引用了某个巨型对象,那么这就是脏卡。当巨型对象的引用为0时,它就会被新生代垃圾回收时回收
<10>并发标记起始时间(jdk9)
当垃圾回收的速度跟不上垃圾产生的速度,那么就
动态调整会根据数据采样来动态调整阈值
2.5垃圾回收调优
- 相关参数
- 相关工具
jmap、jconsole等工具
2.5.1调优前提
- 程序调优包括多个方面,不仅仅是指的GC调优。
2.5.1.1确定调优目标选择合适回收器
- 针对垃圾调优而言,首先需要意识到自己的目标到底是做科学运算工具还是互联网项目等,选择合适的回收器
高吞吐量主要是用ParallelGC
2.5.1.2最快的GC是不发生GC
数据是不是太多?如
数据是不是太臃肿?如:查一个对象就将与之相关的对象都查出来。或对象太大(如包装类型包括16字节的头和4字节的值和约4字节的对齐,比直接用int翻了四倍)
是否存在内存泄漏?如
- 缓存时定义静态map,不断放数据造成内存溢出。
- 可用软引用、弱引用等,或者用第三方缓存实现
2.5.2新生代调优
-
理想情况下新生代内存分配:
-
如果幸存区较小,就会由JVM动态调整晋升阈值,可能会提前把一些对象晋升到老年代,得等到老年代内存不足时才能够将之垃圾回收,变相的延长了垃圾回收的时间。
-
长时间存活的对象应该尽快的晋升到老年区,所以要设置合适的晋升阈值
2.5.3老年代调优
- 为什么老年代越大越好?
因为用户线程和垃圾处理线程变更发执行,会产生浮动垃圾。如果老年代太小可能会导致内存不足,并发失败,退化为SerialOld,响应时间延长的越来越长。 - 为什么可以先不调优老年代
因为若没FULL GC说明老年代区间充裕,先尝试调优新生代。
如果老年代发生了Full-GC,就去观察发生Full-GC的时候老年代的内存占用,将老年代的内存预设调大1/4-1/3
2.5.4案例
三、类加载和字节码技术
3.1类文件结构
-parameters参数是保留arg参数信息。编译后:
第一列是标号
下面是JVM规范下的类文件结构:
第一列是字节数,比如第一行magic魔术占4个字节;接下来两个字节是小版本号;在接下来两个字节是主版本号;
接下来是常量池的信息。
再往后是访问修饰(包是公有?)。
接着是类自己的信息和父类的信息。
接着是接口信息。
在后面是类中变量信息、方法信息;
最后是类的附加的属性信息。
1).魔术信息
用于标识是什么类型的文件。java使用这种方式表明自己是class文件:
2).版本
3).常量池
- 常量类型
- 文件分析
。。。。。。
后面的分析不想写了
4).访问和继承信息
5).field
6).Method信息
- method_init
- method_main
7).附加属性
3.2基本字节码指令
3.2.1基本知识
1).入门
- init
- main
2).javap工具
3)图解运行流程
3.1)常量池载入运行时常量池
3.2).方法字节码放入方法区
3.3).main线程开始运行,分配栈帧内存
绿色是局部变量表,蓝色是操作符栈
3.4).执行引擎执行字节码
3.2.2条件判断指令和循环控制指令
- 条件判断指令
- 循环控制用到的指令其实还是之前的指令
3.2.3构造方法底层指令
1).<cinit()V
这个是整个类的构造方法
- c方法在类加载的初始阶段被调用
2).<init()V
构造对象的构造方法
3.2.4方法调用底层指令
- invokestatic和invokespecial是静态绑定,在字节码指令生成时就能找到是哪个类的哪个方法(构造方法、私有方法、静态方法都是唯一确定的)
- invokevirtual是动态绑定 ,普通的方法可能存在方法重写不能唯一确定
new关键字调用构造方法的时候,首先用new关键字分配了对象在堆空间分配的内存。
分配成功会将对象的引用放入操作数栈。
当引用结束的时候就会出栈,所以要复制一份配合第三行invokespecial的调用。
第四行将剩余的对象引用放在局部变量表中(b)里。
第五行调d的引用
…
行号为20的那一行,调用的是静态方法,而aload_1将对象的引用放在操作栈,但是静态方法无需对象引用,所以下一行pop掉。
综上所述,适用对象调用静态方法,会产生不必要的指令,所以最好不要这样写
3.2.5多态原理
示例代码:
1)获取进程id
运行代码至System.in/read方法,运行jps命令获取进程id
2)运行HSDB工具
- 进入JDK安装目录,查看线程id
- 进入图型界面attach进程id
3)查找某个对象
4)查看对象内存结构
点击超链接可以看到对象的内存结构,此对象没有任何属性,因此只有对象头的 16 字节,前 8 字节是
MarkWord,后 8 字节就是对象的** Class 指针。**
但是目前看不到它的实际地址。
5)查看对象实Class地址
6).查看类的vtable
再打开Inspector,输入类型指针就可以查看完整的类型表示。
7)查看方法地址
多态方法存在于一张vtable的虚方法表中,而前文提到的静态、私有、final方法不会在这里。
该表就在Inspector的最后面,但是无法直接查看其内存地址。需要根据偏移地址和指针地址计算vtable的起始地址。
mem ox000000001b7d41e0 6
中的6,是查看几个word
8)验证方法地址
注意虚方法表是在类的加载过程中的链接阶段就生成的,所以连接过程就是确定了每个虚方法的链接地址
看出来invokevirtual是比较复杂的,不如invokestatic和invokespecial。因为涉及了运行期间动态查找。
3.2.6异常处理底层指令
try-catch
- javap反编译后的重点信息
8 9 11行明显是catch块中的内容,那么是怎么跳转到catch块呢?答:通过Exception Table。
一旦出现异常就和该table进行对比,找到一致的异常类型,进入target所指示行。该行(astore_2)的意义是将异常对象放入e局部变量表的slot2
多个catch
- javap反编译的精简信息
多个异常同时刻只能发生一种,没必要搞三个槽位,所以这里用到了slot槽位复用
一个catch多个异常
三个异常都会进入25行号对应行,astore_1将异常存储到e局部变量表的slot1中,aload_1是将对应的引用e放入操作数栈
finally
- javap反编译后的简略信息
2~7是try块的内容,为什么7会将30赋值给i,明明try块中没有这个语句?
答:finally分支会分别放在try分支和catch分支,保证finally内容一定执行
21~26是catch捕捉不到的异常,也会添加finally语句,这是为了保证finally一定执行,即使出现的异常捕捉不到!
finally面试题
- finally出现return
j建议不要再finally中写return - finally对返回值影响
当catch时:行号4不直接返回,而是将10固定在一个slot(目的是固定返回值),8会重新将slot_1的值载入9返回。
当finally时:会赋值加载20,抛出异常
最终返回10
3.2.7syschronized底层指令
syschronized如何正确的加锁解锁呢?
- 字节码指令
从8行开始进入syschronized块,首先将lock引用放入操作数栈。
9:接下来对引用复制,分别用于加解锁。
10:引用放入slot_2
11:栈顶剩余引用交由monitorenter使用,作用为对引用指向的对象加锁。
12-17:执行sout
若无异常(20-22),调用slot2的引用交由minitorexit解锁。goto到30
根据异常表,若12-22出现异常进入25;若25-28出现异常,再次进入25
若有异常(25~),将异常引用存在slot_3,加载slot_2中的lock引用,调用解锁方法,将异常加载,抛出。
3.3编译期优化和处理
3.3.1默认构造器
- 字节不实现构造器,默认调用父类无参构造器
3.3.2自动拆装箱
3.3.3泛型
- 泛型擦除
java在编译为class文件的过程中,一部分泛型会被擦除为原始类型(若无限定,擦除为Object),当然有一部分时不能擦除的
但是这个类型转换不用自己做,因为JVM底层会做:
会强制类型转换。 - 泛型反射
观察LocalVaribleTypeTable,泛型信息任然存在,但是**局部变量泛型信息不能通过反射获得。**只能获得方法参数和返回值的泛型信息
3.3.4可变参数
- 可变数组相当于一个String[]数组,调用时相当于在()内部传入了对应传参长度的新建String[]
- 如果调用方法不传参数,相当于在()内部传入了new的一个空的的String[],而不是传入null。
3.3.5foreach和switch-String和switch-enum
- foreach
- switch-String
第一个switch先和两个子字符串"hello""world"的哈希码进行匹配,第一个匹配x=0,第二个匹配x=1,都不匹配x=-1。
第二个switch和x进行匹配,对应的x,做出对应的动作。
当字节码冲突,而字符串值又不同的时候:
- switch-enum
去合成类的内部map去找对应枚举对应的数字,通过数字来switch
3.3.6枚举
转换为一个final类,继承父类Enum。枚举类内部有和枚举类中成员数相同的静态常量,还有一个同类数组。另外存在静态代码块来new,并将常量放入数组中。
存在一个values数组用于克隆数组。还有一个valueOf方法通过字符串名称创建实例对象。
3.3.7twr
- try-catch-resource
转化后的最外层的tyr-catch就是自己写的try-catch,内部编译器自己创建了对可能出现的异常捕捉以及最终的finally
3.3.8重写桥接
3.3.9匿名内部类
- 普通匿名内部类
就是额外新建了一个类 - 引用局部变量的匿名内部类
3.4类加载
3.4.1加载
- 内部的c++的instanceKlass不能直接由java访问,需要一个转换过程,需要_java_mirror也就是java类镜像作为桥梁来访问
以String为例,在他的方法区存在instanceKlass,但是java不能直接访问,只能先找到String.class(这个也就是String的类镜像)
每个对象都有对象头(16个字节),其中8个字节对应对象的class地址,如果想通过对象获取class信息,那么就会通过class地址访问person.class,然后通过类镜像找到元空间的instanceKlass,然后获取对应的信息。 - 注意镜像文件放在堆区,而instanceKlass放在方法区。
3.4.2链接
验证
验证字节码是否符合虚拟机规范,同时进行安全性检查。
修改魔数后运行class文件发现格式不正确:ClassFormatError
准备
- 静态变量放在class文件后面(JDK8之后),早期(6之前)是放在instanceKlass后面
也就是说早期静态变量在方法区中,后来静态变量放在堆中。 - 准备阶段只是给静态变量分配空间,赋值在初始化阶段完成
- final修饰的静态基本变量的赋值动作在准备阶段就完成了
- final修饰的静态引用变量的赋值动作还是在初始化阶段就完成了
解析
- 解析将常量池中的符号引用解析为直接引用。
类的加载是懒惰式的,直接加载C不会解析初始化D;而newC的时候会解析初始化C。对比二者说明解析作用:
有C没D,D这时只是一个符号,没有解析和初始化
有C有D
3.4.3初始化
- 即调用方法的阶段,虚拟机会保证类的构造方法的线程安全
- 练习1
ab会,c不会。自己在E中写一个static块,块中写一个输出语句就可以测试出来是否引发初始化。字节码角度:static中只有C
- 练习2
其中私有构造是为了只有自己能初始话
静态内部类只有第一次用到这个类时才会触发类加载、链接、初始化。
第三个,如果你不用这个LazyHolder,那么就不会加载、链接、始化内部类
3.5类加载器
- JDK类加载器是分层级的
- 启动类加载器
- 扩展类加载器
- 应用程序类加载器
- 自定义类加载器
各个类加载器各司其职,每个类加载器只负责自己的一部分,如负责范围如下:
当某级类加载器需要加载时,回去询问上级类加载器是否已经将加载过这个类了。只有所有上级都没有加载这个类,那么才回去加载这个类。
启动类加载器(BootStrap ClassLoader)
可以通过getClassLoader这个方法查看是哪个类加载器加载的:
扩展类加载器(Extension ClassLoader)
默认情况下类都是在classpath下加载的
修改打包地址再次执行
双亲委派模式
所谓双亲委派,就是调用类加载器时,查找类的规则。上级优先加载,上级没有加载,再由我来完成加载。
- classLoader中loadClass的源码
- 该类是否加载:
- 没有加载:
- 如果有上级,委派上级加载parent.loadClass(name,false);
- 如果没有上级了,委派启动类加载器:findBootStrapClassOrNull(name)
- 上级没有找到该类:每个类加载器自己来加载
- 没有加载:
线程上下文加载器
- 注意初始化驱动中的loadInitialDrivers方法:
- 使用ServiceLoader机制加载驱动(SPI)
- 约定如下,在** jar 包的 META-INF/services 包下**,以接口全限定名名为文件,文件内容是实现类名称
- 接着看ServiceLoader.load方法:
- 约定如下,在** jar 包的 META-INF/services 包下**,以接口全限定名名为文件,文件内容是实现类名称
- 使用jdbc.drivers定义的驱动名加载驱动
- 底层实际上使用的ClassLoader.getSystemClassLoader(),也就是应用程序加载器
这里实际上是打破了双亲委派机制,直接使用ApplicationClassLoader
- 底层实际上使用的ClassLoader.getSystemClassLoader(),也就是应用程序加载器
自定义类加载器
- 何时需要自定义类加载器?
- 自定义类加载器的步骤:
注意重写的是findClass方法而不是loadClass方法 - 实现
- 测试
输出true.
输出false.因为只有全类名相同且类加载器相同才会被认为是同一个
四、运行期优化
4.1即时编译
分层编译
两层循环:
- 内存循环创建对象
- 外层循环计时统计,计算创建花费的时间
- 看结果发现前面一些对象时间较大五位数,后面较小,约减小一般,在后面更小直接三位数了,这是为什么呢?
第零层,解释器将字节码解释为真正的机器码。当字节码被反复调用,到达一定阈值会启用编译器,进行编译执行。
编译器分两种C1 C2 - 解释器与编译器的区别
其中C1只是做一些基础的优化,C2会做一些彻底的优化(如逃逸分析)。 - 其中最后阶段用到了逃逸分析(因为new的对象根本会不会被使用,干脆不创建),所以直接变成三位数
根据逃逸程度的不同,进行不同程度的优化(诸如:栈上分配、标量替换、同步消除等优化方法)
方法内联
也是即时编译器优化手段的一种,本质上就是调用函数的时候,如果函数比较短,且是热点代码,就会将函数内代码拷贝到调用者内部去
字段优化
- @Warmup进行热身,测试出哪些是热点代码
- @Weasurement测试方法热身几轮
test1是用成员变量,test2是先将成员变量赋值给局部变量,test3是foreach语法
- 测试结果
使用内联结果发现效率上是差不多的;不使用内敛时三者吞吐量下降且第一种方法明显低于其余二者
4.2反射优化
- 通过反射调用foo方法,前16调用相对较低,从第十七次开始性能稍微好些。从源码角度分析:
看到调用了方法访问器。其默认实现内部会调用NativeMethodAccessorImpl:
有一个值numInvocations,如果大于一个阈值(默认15),就执行一段代码。
大于15就会进行一个替换。会将本地方法访问器替换成运行期间动态生成的新的方法访问器。
如何查阅运行期间动态生成的类的字节码呢?此处使用alibaba的开源工具arthas看到程序生成期间动态生成的字节码。
发现17次开始将反射方法调用转换成了正常方法调用。
五、内存模型JMM
简单的讲,JMM定义了一套在多线程共享数据时,对数据的可见性、有序性和原子性的规则和保障
5.1原子性-synchronized
obj相当于一个房间,该关键字是当一个人进入房间后加上锁。上锁后该房间不再让人进入。
对象监视区(monitor),当一个对象加上该关键字就会进入该区域。owner表示监视的所有者,同时刻只能有一个线程,entrylist,waitSet
当刚开始owner是空着的,可以放入owner,并且将owner锁定。若有第二个线程过来,发现owner存在且锁定,进入entryList.空出来后再次进入owner。
调整:
一个循环有20万次指令执行,但是底层加锁指令会只执行一次。
问题1_数据安全问题:
不一定。
注:不要和内存结构混淆。内存结构用于解决数据如何存放。内存模型用于多线程共享数据。
单线程下不会交错执行,多线程下可能交错执行。因为线程是抢占式调度
5.2可见性
退不出的循环
因为经过优化,高速缓存中的标记还是true。
- 解决方法,添加关键字volatile
这个关键字修饰的变量读取需要到主线程读取,不回去访问高速缓冲区。
可见性
加了sout后就会可见,为什么,是因为底层用了synchornized,而synchornized既可以保证原子性,又可以保证可见性
5.3有序性
诡异的结果
问:可能的结果有几种?
但是结果还可能是0哦,线程2执行后为true(但是这个时候有可能num=2没被执行呢),进入线程1进入if0+0=0
为什么true先赋值了,但是num=2没执行呢?指令重排!
解决方法
有序性理解
多线程下指令重排可能影响正确性
happens-before
5.4CAS与原子类
1.CAS
CAS体现的是一种乐观锁的思想,如多个线程对一个共享的整型变量执行+1操作:
结合CAS和volatile可以实现无锁并发,适应于竞争不激烈、多核CPU的场景
- 因为没有使用synchronized,所以线程不会阻塞,
- 如果竞争激烈,重试必然频繁发生,效率受影响。
2.底层
底层通过Unsafe实现cas指令,下面是使用unsafe对象进行安全保护的例子:
3.原子类
5.5synchronized优化
5.5.1锁膨胀机制
5.5.1.1偏向锁
加了偏向锁后,对象头不够用了。没法存对象的hashcode了,所以访问对象hashCode需要撤销偏向锁。
当只有一个线程访问对象,并对之加锁,锁就会从无锁状态升级为偏向锁。
此时 Mark Word 的结构也就变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作(同步操作就是加锁、解锁操作的总称),即获取锁的过程只需要检查 Mark Word 的锁标记位为偏向锁以及当前线程 ID 等于 Mark Word 的 Thread ID 即可,这样就省去了大量有关锁申请的操作。
5.5.1.2轻量级锁
5.5.1.3重量级锁
重量级锁是由轻量级锁升级而来。当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。此时各个线程之间存在竞争关系。
重量级锁一般用在不追求吞吐量,同步块或者同步方法执行时间较长的场景。
5.5.2锁的自旋
大致相当于:在遇到 synchronized 时,没有拿到锁,但是并不乖乖去阻塞,而是继续执行一些无意义代码。执行完这些代码再看看别人把锁释放没有。如果还是没有释放,那就只好去阻塞了。
适合的场景:锁定时间较短,通过自旋有较大几率获得锁。
不适合的场景:锁定时间长,自旋操作本身浪费了 CPU 性能。
通俗来说就是:“旋”了半天没等到,白“旋”了。
- 自旋重试失败的情况
5.5.5其他优化
减少上锁时间
同步代码块中尽量短