基础篇
初识 JVM
字节码文件详解
jvm 虚拟机的组成
字节码文件的组成
使用 jclasslib 查看字节码文件
下面是一些截图
magic->用来校验文件类型
主副版本号->可能存在一些库的版本和运行jdk版本不一致
常量池->定义一些字符串时会涉及到
方法->涉及到一些字节码底层方法的学习
这块主要涉及到一些 字节码方法的学习,难度还是比较高的
字节码工具->javap, jclasslib, arthas
javap -v 字节码文件名 -> 如果是 jar 包,需要先解压 jar –xvf
jclasslib idea
记得修改后重新编译
arthas
java -jar arthas-boot.jar
# 把字节码保存到本地
dump -d /tmp/output java.lang.String
HASHCODE CLASSLOADER LOCATION null /tmp/output/java/lang/String.class
Affect(row-cnt:1) cost in 72 ms.
# 反编译文件
jad --source-only demo.MathGame
类的生命周期
生命周期概述
加载阶段
# -cp 以指定的主启动类启动
java -cp sa-jdi.jar sun.jvm.hotspot.HSDB
连接阶段
准备
准备阶段为静态变量(static)分配内存并设置初值,每一种基本数据类型和引用数据类型都有其初值。
数据类型 | 初始值 |
---|---|
int | 0 |
long | 0L |
short | 0 |
char | ‘\u0000’ |
byte | 0 |
boolean | false |
double | 0.0 |
引用****数据类型 | null |
public class Student{public static int value = 1;}
在准备阶段会为value分配内存并赋初值为0,在初始化阶段才会将值修改为1。
final修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值。
解析
解析阶段主要是将常量池中的符号引用替换为直接引用,符号引用就是在字节码文件中使用编号来访问常量池中的内容。
初始化阶段
完成静态变量的赋值,已经静态代码块的执行
最后一句话需要注意,clinit方法中的执行顺序与Java中编写的顺序是一致的->一些静态代码块和静态变量执行顺序问题
-XX:+TraceClassLoading
-Xlog:class+load=info
以下几种方式会导致类的初始化:
1.访问一个类的静态变量或者静态方法,注意变量是final修饰的并且等号右边是常量不会触发初始化。
2.调用Class.forName(String className)。
延迟初始化
如果你希望获取 Class 对象而不触发类的初始化,可以使用不同的方式,例如:
ClassLoader.loadClass(String name, boolean initialize): 通过类加载器加载类时可以选择不初始化。
Class.forName(String name, boolean initialize, ClassLoader loader):这是另一个重载的方法,可以指定不执行初始化。
3.new一个该类的对象时。
4.执行Main方法的当前类。
字节码中三个方法的执行顺序
- init方法,会在对象初始化时执行
- main方法,主方法
- clinit方法,类的初始化阶段执行
clinit不会执行的几种情况->这个基本上不用记,如果没有需要初始化阶段做的事,那就不会生成clinit的方法
1.无静态代码块且无静态变量赋值语句。
2.有静态变量的声明,但是没有赋值语句。
3.静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化。
注意:{} 包围的实例初始化代码块,在类的构造方法执行前执行
数组的创建不会导致数组中元素的类进行初始化->只会分配内存空间还有默认初始化值,但并不会加载类
final修饰的变量如果赋值的内容需要执行指令才能得出结果,会执行clinit方法进行初始化。
类加载器
类加载器分类
1.8 之前
启动类加载器
加载 jre/lib
-Xbootclasspath/a:jar包目录
如果用户想扩展一些比较基础的jar包,让启动类加载器加载,有两种途径:
- 放入jre/lib下进行扩展。不推荐,尽可能不要去更改JDK安装目录中的内容,会出现即时放进去由于文件名不匹配的问题也不会正常地被加载。
- 使用参数进行扩展。推荐,使用-Xbootclasspath/a:jar包目录/jar包名 进行扩展,参数中的/a代表新增。
扩展类加载器和应用程序类加载器
扩展类加载器加载 jre/lib/ext
-Djava.ext.dirs=jar包目录
双亲委派模型
引入:为了解决一个类到底应该由谁加载的问题
双亲委派模型的作用:
- 保证核心类库的安全性->因为加载时是先从上面开始加载的,所以就算我们自定义的加载器可以加载,也会先从启动类加载器开始加载
- 避免一个类会被重复加载
向上查找(这句话我理解可以不用说,这个查找的过程只是确认在不在,先后顺序不重要),向下加载(看是不是在自己的加载路径内->联系前面的 URLClassLoader 实现)
String 类能被覆盖么
手动调用 Class.forName("java.lang.String")
,尝试手动加载类,流程分析
- 这个过程遵循双亲委派模型,所以会先去查找这个类是否存在,会查询到启动类加载器已经加载过
- 返回启动类夹杂其加载的String类和启动器加载器的信息(null,前面介绍过)
双亲 -> 英文翻译,意思是父节点
查看类加载器的父子关系
classloader -t
+-BootstrapClassLoader
+-sun.misc.Launcher$ExtClassLoader@5584bc63+-com.taobao.arthas.agent.ArthasClassloader@65b3bea3+-sun.misc.Launcher$AppClassLoader@18b4aac2
Affect(row-cnt:4) cost in 4 ms.
打破双亲委派模型
自定义类加载器加载类
/*** 打破双亲委派机制 - 自定义类加载器*/
public class BreakClassLoader1 extends ClassLoader {private String basePath;private final static String FILE_EXT = ".class";public void setBasePath(String basePath) {this.basePath = basePath;}private byte[] loadClassData(String name) {try {String tempName = name.replaceAll("\\.", Matcher.quoteReplacement(File.separator));FileInputStream fis = new FileInputStream(basePath + tempName + FILE_EXT);try {return IOUtils.toByteArray(fis);} finally {IOUtils.closeQuietly(fis);}} catch (Exception e) {System.out.println("自定义类加载器加载失败,错误原因:" + e.getMessage());e.printStackTrace(); // 输出详细的堆栈信息return null;}}@Overridepublic Class<?> loadClass(String name) throws ClassNotFoundException {if(name.startsWith("java.")){return super.loadClass(name);}byte[] data = loadClassData(name);return defineClass(name, data, 0, data.length);}public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, IOException {BreakClassLoader1 classLoader1 = new BreakClassLoader1();classLoader1.setBasePath("D:\\github\\classloader-api\\target\\classes\\");Class<?> clazz1 = classLoader1.loadClass("org.zhouzhou.jvm.api.A");System.out.println("自定义类加载器的父加载器: " +classLoader1.getParent());BreakClassLoader1 classLoader2 = new BreakClassLoader1();classLoader2.setBasePath("D:\\github\\classloader-api\\target\\classes\\");Class<?> clazz2 = classLoader2.loadClass("org.zhouzhou.jvm.api.A");System.out.println(clazz1 == clazz2); // false 不同类加载器加载的同一个类不equalsThread.currentThread().setContextClassLoader(classLoader1);System.out.println(Thread.currentThread().getContextClassLoader());System.in.read();}
}
线程上下文类加载器
DriverManager
是 boot加载器加载的,DriverManager
通过上下文加载器去加载驱动类
Osgi 框架的类加载器(不做要求)
使用阿里arthas不停机解决线上问题
- 在出问题的服务器上部署一个 arthas,并启动。
- jad --source-only 类全限定名 > 目录/文件名.java jad 命令反编译,然后可以用其它编译器,比如 vim 来修改源码
- mc –c 类加载器的hashcode 目录/文件名.java -d 输出目录
mc 命令用来编译修改过的代码 - retransform class文件所在目录/xxx.class
用 retransform 命令加载新的字节码
注意事项:
1、程序重启之后,字节码文件会恢复,除非将class文件放入jar包中进行更新。
2、使用retransform不能添加方法或者字段,也不能更新正在执行中的方法。
JDK9之后的类加载器
1.启动类加载器使用Java编写,位于jdk.internal.loader.ClassLoaders类中。
Java中的BootClassLoader继承自BuiltinClassLoader实现从模块中找到要加载的字节码资源文件。
启动类加载器依然无法通过java代码获取到,返回的仍然是null,保持了统一。
2、扩展类加载器被替换成了平台类加载器(Platform Class Loader)。
平台类加载器遵循模块化方式加载字节码文件,所以继承关系从URLClassLoader变成了BuiltinClassLoader,BuiltinClassLoader实现了从模块中加载字节码文件。平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊的逻辑。
JVM的内存区域
运行时数据区
Java虚拟机在运行Java程序过程中管理的内存区域,称之为运行时数据区
程序计数器
ifne 9 +6 这里就存在指令的跳转
栈
Java 虚拟机栈
Java虚拟机栈(Java Virtual Machine Stack)采用栈的数据结构来管理方法调用中的基本数据,先进后出(First In Last Out),每一个方法的调用使用一个栈帧(Stack Frame)来保存。
Java虚拟机栈随着线程的创建而创建,而回收则会在线程的销毁时进行。由于方法可能会在不同线程中执行,每个线程都会包含一个自己的虚拟机栈。如下就有两个线程的虚拟机栈,main线程和线程A。
Java虚拟机栈的栈帧中主要包含三方面的内容:
- 局部变量表,局部变量表的作用是在运行过程中存放所有的局部变量
局部变量的槽可能存在复用情况(当某个变量已经不再生效范围的时候,后面的变量可以使用前面已经失效的变量的槽)->局部变量的最大槽数
- 操作数栈,操作数栈是栈帧中虚拟机在执行指令过程中用来存放临时数据的一块区域
- 帧数据,帧数据主要包含动态链接、方法出口、异常表的引用
本地方法栈
栈溢出
堆
arthas 查看 dashboard -i 3000
每隔3秒刷新一次
方法区
skywalking
字符串常量池
字符串变量相加,String c = a + b;
底层调用 StringBuilder 的 append,会生成一个新对象放在堆里面,String c = "1" + "2";
编译阶段会直接把两个字符串常量连接起来
直接内存
直接内存(Direct Memory)并不在《Java虚拟机规范》中存在,所以并不属于Java运行时的内存区域。
在 JDK 1.4 中引入了 NIO 机制,使用了直接内存,主要为了解决以下两个问题:
1、Java堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。
2、IO操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到Java堆中。
现在直接放入直接内存即可,同时Java堆上维护直接内存的引用,减少了数据复制的开销。写文件也是类似的思路。
JVM的垃圾回收
方法区的回收(不做要求)
堆回收
判断依据->对象是否呗引用
当存在循环引用的情况
引用计数法
引用计数法会为每个对象维护一个引用计数器,当对象被引用时加1,取消引用时减1。
引用计数法的优点是实现简单,C++中的智能指针就采用了引用计数法,但是它也存在缺点,主要有两点:
1.每次引用和取消引用都需要维护计数器,对系统性能会有一定的影响
2.存在循环引用问题,所谓循环引用就是当A引用B,B同时引用A时会出现对象无法回收的问题。
可达性分析法->解决强引用对象的回收问题
分析循环引用存在时的可达性分析
A B 会随着线程对象这个 根对象回收
哪些对象被称之为GC Root对象呢?
- 线程Thread对象,引用线程栈帧中的方法参数、局部变量等。
- 系统类加载器加载的java.lang.Class对象,引用类中的静态变量。
- 监视器对象,用来保存同步锁synchronized关键字持有的对象。
- 本地方法调用时使用的全局对象。
使用 MAT 查看GC Root
# arthas 导出堆内存快照
heapdump 目录/test2.hprof
使用 MAT 工具(需要 jdk17以上)
五种对象引用->其余引用类型的回收规则定义
可达性算法中描述的对象引用,一般指的是强引用,即是GCRoot对象对普通对象有引用关系,只要这层关系存在,普通对象就不会被回收。除了强引用之外,Java中还设计了几种其他引用方式:(也即在这五种对象引用类型中的回收规则)
- 软引用-> 常用于缓存
- 弱引用->类似软引用,唯一区别不管内存够不够都会被回收->->ThreadLocal
- 虚引用->也叫幽灵引用/幻影引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java中使用PhantomReference实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现。
DirectByteBuffer 中引用的内存空间的回收
- 终结器引用,暂时略过
垃圾回收算法
核心思想
1、找到内存中存活的对象
2、释放不再存活对象的内存,使得程序能再次利用这部分空间
评价标准
- 吞吐量->GC暂停时间占比
- 最大暂停时间
- 堆使用效率
标记-清除算法
标记清除算法的核心思想分为两个阶段:
1.标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
2.清除阶段,从内存中删除没有被标记也就是非存活对象。
标记-清除算法缺点
1.内存碎片问题
2.分配速度慢。由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间。 我们需要用一个链表来维护,哪些空间可以分配对象,很有可能需要遍历这个链表到最后,才能发现这块空间足够我们去创建一个对象。如下图,遍历到最后才发现有足够的空间分配3个字节的对象了。如果链表很长,遍历也会花费较长的时间。
复制算法
复制算法的核心思想是:
1.准备两块空间From空间和To空间,每次在对象分配阶段,只能使用其中一块空间(From空间)。
对象A首先分配在From空间:
2.在垃圾回收GC阶段,将From中存活对象复制到To空间。
在垃圾回收阶段,如果对象A存活,就将其复制到To空间。然后将From空间直接清空。
3.将两块空间的From和To名字互换。
接下来将两块空间的名称互换,下次依然在From空间上创建对象。
优点:
- 吞吐量高,复制算法只需要遍历一次存活对象复制到To空间即可,比标记-整理算法少了一次遍历的过程,因而性能较好,但是不如标记-清除算法,因为标记清除算法不需要进行对象的移动
- 不会发生碎片化,复制算法在复制之后就会将对象按顺序放入To空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间。
缺点:
内存使用效率低,每次只能让一半的内存空间来为创建对象使用。
标记整理算法
标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案。
核心思想分为两个阶段:
1.标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
2.整理阶段,将存活对象移动到堆的一端。清理掉存活对象的内存空间。
优点:
- 内存使用效率高,整个堆内存都可以使用,不会像复制算法只能使用半个堆内存
- 不会发生碎片化,在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间
缺点:
整理阶段的效率不高,整理算法有很多种,比如Lisp2整理算法需要对整个堆中的对象搜索3次,整体性能不佳。可以通过Two-Finger、表格算法、ImmixGC等高效的整理算法优化此阶段的性能。
分代垃圾回收算法
现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收算法(Generational GC)。
分代垃圾回收将整个内存区域划分为年轻代和老年代:
1、分代回收时,创建出来的对象,首先会被放入Eden伊甸园区。
2、随着对象在Eden区越来越多,如果Eden区满,新创建的对象已经无法放入,就会触发年轻代的GC,称为Minor GC或者Young GC。
Minor GC会把需要eden中和From需要回收的对象回收,把没有回收的对象放入To区。(Eden区数据会被清空)
3、接下来,S0会变成To区,S1变成From区。当eden区满时再往里放入对象,依然会发生Minor GC。
此时会回收eden区和S1(from)中的对象,并把eden和from区中剩余的对象放入S0。
注意:每次Minor GC中都会为对象记录他的年龄,初始值为0,每次GC完加1。
4、如果Minor GC后对象的年龄达到阈值(最大15,默认值和垃圾回收器有关),对象就会被晋升至老年代。
当老年代中空间不足,无法放入新的对象时,先尝试minor gc如果还是不足,就会触发Full GC,Full GC会对整个堆进行垃圾回收。
如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出Out Of Memory异常。
组合关系
Serial-SerialOld->单线程年轻代/老年代回收算法
ParNew->年轻代多线程回收算法
优点
多CPU处理器下停顿时间较短
缺点
吞吐量和停顿时间不如G1,所以在JDK9之后不建议使用
适用场景
JDK8及之前的版本中,与CMS老年代垃圾回收器搭配使用
CMS->老年代垃圾回收器
Parallel Scavenge -> 多线程,年轻代,关注吞吐量,自动调整堆内存
不能保证单次的停顿时间
Parallel Old -> 多线程,配合Parallel Scavenge
略
G1
把内存拆分为很多小的 Region,然后新生代和老年代各自对应一些 Region,回收的时候尽可能挑选停顿时间最短以及回收对象最多的 Region,尽量保证我们指定的垃圾回收系统停顿时间
调优思路
大内存机器建议使用 G1,因为 ParNew + CMS,只要等内存满了才会触发 GC,此时一旦 GC 会去回收几十G垃圾