本文已参与「新人创作礼」活动,一起开启掘金创作之路。
环境
jdk
jdk1.8(使用最广的版本,建议新项目选择jdk11,垃圾回收器更优秀)
一段简单代码
public class Person {
public int work()throws Exception{
int x =1;
int y =2;
int z =(x+y)*10;
return z;
}
public static void main(String[] args) throws Exception{
Person person = new Person();//person 栈中--、 new Person 对象是在堆
System.out.println(person.work());
}
}
过程解释
类加载过程
加载--->连接(验证-->准备-->解析)-->初始化-->使用-->卸载
加载
我们都知道当我们编写一个类时就会生成一个.java文件,比如我们上面的代码在我机器上就有Person.java文件
点进去看看 从下图可以看见就是我们在idea编写的代码
由于jvm会识别class文件格式,因此我们常用的idea会帮我们自动编译好我们所写的类
连接
使用命令javap -verbose Person.class查询该文件的字节码
D:\BaiduNetdiskDownload\ref-jvm3\out\production\ref-jvm3\gcOom>javap -verbose Person.class
Classfile /D:/BaiduNetdiskDownload/ref-jvm3/out/production/ref-jvm3/gcOom/Person.class
Last modified 2022年2月7日; size 745 bytes
MD5 checksum b4981e183f37ec9bba52d52e4beae0ff
Compiled from "Person.java"
public class gcOom.Person
minor version: 0 // 1.小版本
major version: 52 // 2.大版本
flags: (0x0021) ACC_PUBLIC, ACC_SUPER // 3.访问标志
this_class: #2 // gcOom/Person
super_class: #7 // java/lang/Object
interfaces: 0, fields: 0, methods: 3, attributes: 1
Constant pool: // 4.类常量池
#1 = Methodref #7.#30 // java/lang/Object."<init>":()V
#2 = Class #31 // gcOom/Person
#3 = Methodref #2.#30 // gcOom/Person."<init>":()V
#4 = Fieldref #32.#33 // java/lang/System.out:Ljava/io/PrintStream;
#5 = Methodref #2.#34 // gcOom/Person.work:()I
#6 = Methodref #35.#36 // java/io/PrintStream.println:(I)V
#7 = Class #37 // java/lang/Object
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 LocalVariableTable
#13 = Utf8 this
#14 = Utf8 LgcOom/Person;
#15 = Utf8 work
#16 = Utf8 ()I
#17 = Utf8 x
#18 = Utf8 I
#19 = Utf8 y
#20 = Utf8 z
#21 = Utf8 Exceptions
#22 = Class #38 // java/lang/Exception
#23 = Utf8 main
#24 = Utf8 ([Ljava/lang/String;)V
#25 = Utf8 args
#26 = Utf8 [Ljava/lang/String;
#27 = Utf8 person
#28 = Utf8 SourceFile
#29 = Utf8 Person.java
#30 = NameAndType #8:#9 // "<init>":()V
#31 = Utf8 gcOom/Person
#32 = Class #39 // java/lang/System
#33 = NameAndType #40:#41 // out:Ljava/io/PrintStream;
#34 = NameAndType #15:#16 // work:()I
#35 = Class #42 // java/io/PrintStream
#36 = NameAndType #43:#44 // println:(I)V
#37 = Utf8 java/lang/Object
#38 = Utf8 java/lang/Exception
#39 = Utf8 java/lang/System
#40 = Utf8 out
#41 = Utf8 Ljava/io/PrintStream;
#42 = Utf8 java/io/PrintStream
#43 = Utf8 println
#44 = Utf8 (I)V
{
public gcOom.Person();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LgcOom/Person;
public int work() throws java.lang.Exception;
descriptor: ()I
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
LineNumberTable:
line 10: 0
line 11: 2
line 12: 4
line 13: 11
LocalVariableTable:
Start Length Slot Name Signature
0 13 0 this LgcOom/Person;
2 11 1 x I
4 9 2 y I
11 2 3 z I
Exceptions:
throws java.lang.Exception
public static void main(java.lang.String[]) throws java.lang.Exception;
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class gcOom/Person
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
11: aload_1
12: invokevirtual #5 // Method work:()I
15: invokevirtual #6 // Method java/io/PrintStream.println:(I)V
18: return
LineNumberTable:
line 16: 0
line 17: 8
line 18: 18
LocalVariableTable:
Start Length Slot Name Signature
0 19 0 args [Ljava/lang/String;
8 11 1 person LgcOom/Person;
Exceptions:
throws java.lang.Exception
}
SourceFile: "Person.java"
Class文件结构
从上面加上了解能知道class文件结构大致分为魔术(一个特殊标志,表示是jvm能识别的文件类似于图片.jpg一样) 这里不知道读者有没有疑问,为什么class文件不像其他文件一样用后缀来标识呢?因为后缀容易被改变,所以java将标识设计在内部,更安全。下面引用《深入理解java虚拟机》来进一步解释
魔数的使用主要是基于安全考虑,文件扩展名可以随意更改。魔数可以由文件格式制定者自由选择,只要没被使用。java的魔数是0xCAFEBABE。
紧接着魔数的 4 个字节存储的是 Class 文件的版本号:第 5 和第 6 个字节是次版本号(MinorVersion),第 7 和第 8 个字节是主版本号(MajorVersion)。版本的用处就是标识当前class能否在当前jvm中运行,高本版向下兼容。
接着就是常量池,访问标志,类索引、父类索引与接口索引集合,字段表集合,方法表集合,属性表集合。
类加载器
我们已经知道类最终变成class字节码,可以由jvm识别和执行,那么类在加载的时候,是否都用一个类加载器,如果用同一个类加载器,又怎么能保证java的核心类不会被篡改覆盖导致出现安全问题,那么这个时候聪明的先辈们就提出了双亲委派模型。
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。
jdk提供了三层类加载器,BootstrapClassLoader底层由C实现(加载java的核心类),ExtentionClassLoader(加载lib/ext目录下的jar包和.class文件),ApplicationClassLoader(加载我们编写的java类),CustomClassLoader(自定义加载器)
java内存模型
从上面我们已经知道一个类怎么被加载,那么被加载的这个类里面的数据信息又放到哪里的呢?肯定大家都知道在内存中,但如果在jvm里内存就是一整块,那么怎么使用以及回收呢?有些数据是线程独享的,有些是线程共享的那么应该如何划分呢?这个时候就引出了运行时数据区域这个概念,运行时数据区中的线程共享和线程独享区域再次详细划分。
这个时候是否有疑问为啥要这么分,首先分这几块是jvm虚拟机规范中明确规定的,这个时候大家思考一下为啥堆和方法区是线程共享的,其他的是线程私有的?要回答这个问题需要弄清楚每块存放什么数据。
比如在方法区存放的就是:静态变量,常量池,运行时常量池,类信息,即时编译器编译后的代码缓存。
堆:绝大多数对象的分配都在上面,是运行时数据区占比最大的一块。
虚拟机栈和本地方法栈:存放方法中的局部变量表,操作数栈,动态连接,返回地址。
程序计数器:记录当前线程执行到当前方法哪一行了,当再次切换到执行权时从当前下一行开始执行。
从上面我们是不是就知道栈是来承载方法的,而方法的执行必须跟线程的生命周期一样,而程序计数器又是来记录线程的执行位置,因此这三个必须是线程私有的,也没必须做成共享的。
从上面我们就知道了我们最开始的那段代码,也就是Person类最终将它的信息保存到了方法区中。 new Person()新建的对象保存到堆中。
对象在堆上分配
堆
堆是否是一整块呢?如果了解过分代回收理论就知道对于堆有根据分代理论实现的,也有不根据分代理论实现的,具体要根据实现的垃圾回收器来进行描述,现在主要按照分代理论来说。
弱分代假说:绝大多数对象都是朝生夕死的。
强分代假说:熬过越多次垃圾回收的对象就越难消亡。
跨代引用假说:跨代引用相对于同代引用来说仅占极少数
根据这三个假说的理论基础,又将堆划分为新生代以及老年代,老年代存放那些难以消亡的对象,新生代存放绝大多数新创建的对象,如果是大对象会直接存放到老年代,新生代再根据需要划分为eden区和两个survivor区。虽然跨代引用比较少,但是还是存在因此在HostSport通过卡表来进行解决这个问题。
思考为什么新生代还要分成eden区和两个survivor区?
那是因为新生代分配的对象要想进入老年代必须要达到一定的标准(年龄达到一定数值,动态年龄调整,空间分配担保),加上在垃圾回收上面采用的是复制算法,如果不这样分那么将浪费一般的空间。而这三者默认比例是8:1:1,那么就只会浪费1/10的空间。
对象的分配
对象的分配方式有两种:指针碰撞,空闲列表
指针碰撞:适应于空间规整,在空闲和使用之间存在一个指针,当需要分配时只需要移动指针。
空闲列表:适用于不规整的空间中对象分配,使用一个列表来记录那块区域未分配。
分配安全的保证
cas+重试和本地线程分配缓冲
cas+重试:优点使用的时候才分配,不占空间,缺点大量重试导致性能下降。
TLAB(本地线程分配缓冲):在线程创建时就开辟一个空间用于当前线程进行对象的分配,优点提前分配不会存在冲突,简单高效。缺点提前分配浪空间。默认是此方式。
思考:分配方式二者如和选择?
主要根据当前垃圾回收机制是否能将空间变得规整,比如标记压缩和复制算法就可以选择指针碰撞,而标记清除就可以选择空闲列表。
对象的访问
现在我们根据上面一步一步,已经将对象分配在堆上了,那么在使用时就需要进行访问,对象的访问方式主要有两种,一种是直接指针和另一种句柄。
二者的对比
句柄
需要额外在堆上开辟一块空间作为句柄池,reference指向句柄地址,句柄地址又会存类型数据地址以及实例数据地址,一次查找需要两次定位。
优点:移动对象的时候不需要改变refrence的指针地址。
缺点:需要两次定位,在大量操作时还是会存在性能问题。
直接指针
reference中记录的是实例数据的地址,实例数据需要在对象头中记录类型数据的地址,通过一次访问就能获取到实例数据。HotSport默认使用此方式进行对象访问,因为在java中,对象的创建和访问是十分频繁的。
优点:访问速度快。
缺点:移动对象时需要改变reference的指针地址,增加额外开销。
对象的内存布局
对象头(mark word,klasspoint,数组长度),实例数据,对齐填充。
对象的回收
为什么要对对象进行回收?回收的意义?以及如何确定对象能被回收?如何回收? 对象回收的意义当然是保持程序正常运行,垃圾对象被清除掉用于新对象的分配,因此回收垃圾对象是十分有意义的。
垃圾对象的确定
确定对象是否是垃圾主要有两种算法。
| 名称 | 描述 | 优点 | 缺点 |
|---|---|---|---|
| 引用计数法 | 对象每被引用一次就改变对象中维护一个引用计数器的值加一,每失去一次引用就减一直到为0就认为对象,不再被任何对象引用就是垃圾,可以被回收。 | 实现简单,能快速高效统计和分辨对象是否为垃圾,次数为0就是垃圾。 | 为了引用计数的正确性需要额外大量开销来维护(内部结构问题,通过引入外部结构来解决就会增加额外的维护成本),该算法不能解决对象之间循环引用对象的清除。 |
| 可达分析法 | 指从GC ROOT 出发能到达的对象就是存活的对象,不能到达的就会被标记可以被清除。注意就算被标记为可清除,并不一定会清除,还要看对象是否可以被拯救。 | 查找准确度高,不依赖外部结构 | 遍历效率是GC的最大瓶颈 |
垃圾回收算法
| 名称 | 描述 | 优点 | 缺点 |
|---|---|---|---|
| 标记-清除 | 主要分为两个步骤,第一步根据gcroot标记还存活的对象,第二步将未被标记的对象也就是垃圾直接进行清除。 | 是最先提出的算法,为后续算法做出铺垫。实现简单 | 回收效率不确定,当有大量对象回收时,停顿时间会增加,会产生大量空间碎片 |
| 复制-算法 | 将内存划分成相等的两部分,每次使用只用其中一部分,在回收的时候将存活的对象复制到空闲的那一部分,然后直接释放掉之前的那一部分。如果有大量存活的对象需要复制,效率会降低。 | 实现简单 | 会浪费一半的空间 |
| 标记-整理 | 大致的执行步骤跟标记清除差不多,唯一不同的是标记后的下一个阶段并不是直接清除,而是将存活的对象,朝着一端移动,然后释放掉存活的边界之外的内存。 | 空间利用率高,空间规整 | 回收速度慢,stw时间会增加。 |
垃圾回收处理器
我们已经知道如何确定对象是否该被清除,以及清除的算法,那么就可以根据这些来设计垃圾回收处理器来实现垃圾的回收,当然前辈们已经开发出许多经典的垃圾回收处理器了。
完整过程视频演示
总结
本文从一段代码引出在jvm中是如何执行的,存放到哪里,如何进行对象的分配以及对象的访问,再到垃圾的回收,由于篇幅有限,还有很多细节并没有描述出来,比如HostSport中的一些实现细节三色标记,读写屏障,卡表,以及根节点枚举,安全点以及安全区域等。最后推荐大家一定多看看这本神书《深入理解java虚拟机》。