之前跟着视频学完,总感觉心里不踏实,还是看一看书吧
JVM构成
程序计数器(线程私有)
用于保存JVM中下一条所要执行的指令的地址
-
CPU会为每个线程分配时间片,当当前线程的时间片使用完以后,CPU就会去执行另一个线程中的代码
-
程序计数器是每个线程所私有的,当另一个线程的时间片用完,又返回来执行当前线程的代码时,通过程序计数器可以知道应该执行哪一句指令
-
唯一一个不会存在内存溢出的区
虚拟机栈(线程私有)
- 每个线程运行需要的内存空间,称为虚拟机栈JVM stacks
- 每个栈由多个栈帧组成,对应着每次调用方法时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的方法, 在栈的顶部
本地方法栈
- 与JVM栈类似,但是服务的对象不一样,本地方法栈是为native方法服务的
- hotspot中虚拟机栈与本地方法栈合二为一
堆(线程共有)
- 所有线程共享,JVM创建的时候就创建了
- 存放实例化对象,GC的集中区域就是堆区
- 所有的对象实例及数组都分配在堆上(实际上因为即时编译技术,有些对象被优化到栈上分配)
- 在JDK1.7 字符串常量池被从方法区拿到了堆中
方法区
-
所有线程共享,JVM创建的时候就创建了
-
用于存储类定义信息,常量,静态变量,即时编译器编译后的代码缓存数据。
-
运行时常量池也在这儿
在JDK1.6及之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代(位于堆内存中)
在JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代
在JDK1.8 hotspot移除了永久代用元空间取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(堆外内存)
作者:Himeros 链接:https://www.zhihu.com/question/377418017/answer/1062033254 来源:知乎 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
-
一般情况下,方法区中的数据是不会被回收的。
-
编译后的字节码指令也在这儿,所谓的pc存储的地址,就是指向这个区域。
-
在本地内存通过元空间实现(jdk8及之后)
-
方法区的大小可以是固定的或可扩展的
-
方法区的大小决定了系统可以保存多少个类,**如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:**java.lang.OutOfMemoryError: PermGen space 或者java.lang.OutOfMemoryError: Metaspace
-
关闭JVM就会释放方法区的内存
直接内存
- 属于操作系统,常见于NIO操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受JVM内存回收管理
好问题辨析
常量池与运行时常量池的关系
- 常量池
- 常量池是*.class文件中的一张表(如上图中的constant pool),虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息
- 运行时常量池
- 常量池是*.class文件中的,当该类被加载以后,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
常量池与字符串常量池的关系
常量池在JDK1.6及之前,是包含了字符串常量池的
特征
- 常量池中的字符串仅是符号,只有在被用到时才会转化为对象
- 利用串池的机制,来避免重复创建字符串对象
- 字符串变量拼接的原理是StringBuilder
- 字符串常量拼接的原理是编译器优化
- 可以使用intern方法,主动将串池中还没有的字符串对象放入串池中
- 注意:无论是串池还是堆里面的字符串,都是对象
用来放字符串对象且里面的元素不重复
public class StringTableStudy {
public static void main(String[] args) {
String a = "a";
String b = "b";
String ab = "ab";
}
}
常量池中的信息,都会被加载到运行时常量池中,但这时a b ab 仅是常量池中的符号,还没有成为java字符串
下面是虚拟机中执行编译的方法(框内的是真正编译执行的内容,#号的内容需要在常量池中查找
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: return
- 当执行到 ldc #2 时,会把符号 a 变为 “a” 字符串对象,并放入串池中(hashtable结构 不可扩容)
- 当执行到 ldc #3 时,会把符号 b 变为 “b” 字符串对象,并放入串池中
- 当执行到 ldc #4 时,会把符号 ab 变为 “ab” 字符串对象,并放入串池中
- 最终StringTable [“a”, “b”, “ab”]
注意:字符串对象的创建都是懒惰的,只有当运行到那一行字符串且在串池中不存在的时候(如 ldc #2)时,该字符串才会被创建并放入串池中。
使用拼接字符串变量对象创建字符串的过程
public class StringTableStudy {
public static void main(String[] args) {
String a = "a";
String b = "b";
String ab = "ab"; //在StringTable中
//拼接字符串对象来创建新的字符串
String ab2 = a+b; //new StringBuilder() 在堆中
}
}
反编译后的结果
Code:
stack=2, locals=5, args_size=1
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: new #5 // class java/lang/StringBuilder
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
16: aload_1
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String
;)Ljava/lang/StringBuilder;
20: aload_2
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String
;)Ljava/lang/StringBuilder;
24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/Str
ing;
27: astore 4
29: return
通过拼接的方式来创建字符串的过程是:StringBuilder().append(“a”).append(“b”).toString()
最后的toString方法的返回值是一个新的字符串,但字符串的值和拼接的字符串一致,但是两个不同的字符串,一个存在于串池之中,一个存在于堆内存之中
String ab = "ab";
String ab2 = a+b;
//结果为false,因为ab是存在于串池之中,ab2是由StringBuilder的toString方法所返回的一个对象,存在于堆内存之中
System.out.println(ab == ab2); //false
使用拼接字符串常量对象的方法创建字符串
public class StringTableStudy {
public static void main(String[] args) {
String a = "a";
String b = "b";
String ab = "ab"; //串池中,1.8以后串池在堆中
String ab2 = a+b; //对象拼接,StringBuilder().append(“a”).append(“b”).toString() 堆中
String ab3 = "a" + "b"; //字符串拼接, 串池中
System.out.println(ab == ab2); //false
System.out.println(ab == ab3); //true
}
}
反编译后的结果
Code:
stack=2, locals=6, args_size=1
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: new #5 // class java/lang/StringBuilder
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
16: aload_1
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String
;)Ljava/lang/StringBuilder;
20: aload_2
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String
;)Ljava/lang/StringBuilder;
24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/Str
ing;
27: astore 4
//ab3初始化时直接从串池中获取字符串
29: ldc #4 // String ab
31: astore 5
33: return
- 使用拼接字符串常量的方法来创建新的字符串时,因为内容是常量,javac在编译期会进行优化,结果已在编译期确定为ab,而创建ab的时候已经在串池中放入了“ab”,所以ab3直接从串池中获取值,所以进行的操作和 ab = “ab” 一致。
- 使用拼接字符串变量的方法来创建新的字符串时,因为内容是变量,只能在运行期确定它的值,所以需要使用StringBuilder来创建
String的intern方法
intern方法 1.6 复制
调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中
- 如果串池中没有该字符串对象,会将该字符串对象复制一份,再放入到串池中
- 如果有该字符串对象,则放入失败
无论放入是否成功,都会返回串池中的字符串对象
注意:此时无论调用intern方法成功与否,串池中的字符串对象和堆内存中的字符串对象都不是同一个对象
intern方法 jdk1.8 不复制
调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中
- 如果串池中没有该 字符串对象,则放入成功
- 如果有该字符串对象,则放入失败
无论放入是否成功,都会返回串池中的字符串对象
注意:此时如果调用intern方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象
String s1 = new String(“abc");
是在堆和串池中各创建一个对象s1.intern();
是尝试把堆中对象的引用放入串池,始终返回串池中的对象
public class Main {
public static void main(String[] args) {
//"a" "b" 被放入串池中,str则存在于堆内存之中
String str = new String("a") + new String("b"); //堆中
//调用str的intern方法,这时串池中没有"ab",则会将该 字符串对象 放入到串池中,此时堆内存与串池中的"ab"是同一个对象
String st2 = str.intern(); //池中,但是保存的是堆对象的引用
//给str3赋值,因为此时串池中已有"ab",则直接将串池中的内容返回
String str3 = "ab"; //池中,保存的是堆对象的引用
//因为堆内存与串池中的"ab"是同一个对象,所以以下两条语句打印的都为true
System.out.println(str == st2); //true
System.out.println(str == str3); //true
}
}
垃圾回收是否涉及栈内存?
- 不需要。因为虚拟机栈中是由一个个栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈。所以无需通过垃圾回收机制去回收内存。
栈内存的分配越大越好吗?
- 不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。
- -Xss size: 为栈内存指定大小。中间不要空格
- Linux/x64、macOS、Oracle Solaris/x64都是默认1024KB
- Windows默认值是根据根据windows虚拟内存决定的
方法内的局部变量是否是线程安全的?
-
如果方法内局部变量没有逃离方法的作用范围,则是线程安全的
-
如果如果局部变量引用了对象,并逃离了方法的作用范围,则需要考虑线程安全问题
-
Java.lang.stackOverflowError 栈内存溢出的发生原因?
- 虚拟机栈中,一直入栈不出栈,栈帧过多(比如无限递归)
- 每个栈帧所占用过大
服务器CPU占用过高怎么解决
- 运行某些程序的时候,可能导致CPU的占用过高,这时需要定位占用CPU过高的线程
- top命令,查看是哪个进程占用CPU过高
- 通过ps命令进一步查看是哪个线程占用CPU过高:
ps H -eo pid, tid(代表线程id), %cpu | grep 32655(这是刚才通过top查到的进程号)
jstack 进程id
通过查看进程中的线程的nid,刚才通过ps命令看到的tid来对比定位,注意jstack查找出的线程id是16进制的,需要转换
Java对象头结构
在Hotspot中一个Java对象包含如下三个部分:
- 对象头
- 实例信息
- 对齐信息
可见,对象头存在于java堆中,是java对象的一部分。
访问java对象的两种方式
句柄
- 这种方式下,堆中会划分一块区域作为句柄池。
- 栈中的reference即为句柄的内存地址。
- 一个句柄包含对象的实例数据地址(具体的对象,堆)和对象类型数据地址(类定义信息,方法区)。
- 优点:对象的地址改变(比如gc后被移动),仅需要改变对应句柄中的对象指针。reference不需要改变,因为句柄在句柄池中的位置不改变。
直接指针
- reference存放的内容从以往的句柄直接变成了对象的指针。对象的定义信息的指针放在对象头
- 优点:快,不需要二次寻址,hotspot就是这种方式(如果使用了shenandoah收集器,会有一次额外的转发)
内存异常
堆内存溢出(OutOfMemoryError)
指的是分配给应用程序的内存空间被用完了,再分配就只能溢出了,也就是没法分配了只能抛出异常。
栈溢出(StackOverflowError)
指的是栈容量无法容纳新的栈帧而抛出的异常(区别指针越界)
如果允许栈动态扩容,那么抛出的将会是OutOfMemoryError
方法区溢出
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误
内存泄漏
指的是应用程序不再使用的内存空间,无法被正常回收,造成内存浪费。
垃圾收集器
引用计数法
弊端:循环引用时,两个对象的计数都为1,导致两个对象都无法被释放
可达性分析算法
- JVM中的垃圾回收器通过可达性分析来探索所有存活的对象
- 扫描堆中的对象,看能否沿着GC Root对象为起点的引用链找到该对象,如果找不到,则表示可以回收
- 可以作为GC Root的对象
- System Class:系统类,由启动类加载器加载的类,核心的类。(Object\String…)
- Busy Monitor:被加锁的对象
- Thread:活动线程中使用的对象,虚拟机栈(栈帧中的本地变量表)中引用的对象。
- Native Stack:本地方法栈中JNI(Java Native Interface, 即一般说的Native方法)引用的对象
强引用 | 被GC Root直接引用的对象 | 只有所有GC Root断开引用后,才能被回收 |
---|---|---|
软引用 | 被GC Root间接引用的对象 | 在不被任何一个GC Root直接引用后,当一次垃圾回收后,内存仍然不足时,回收软引用对象 |
弱引用 | 被GC Root间接引用的对象 | 在不被任何一个GC Root直接引用后,当发生垃圾回收,不管空间够不够 都会回收弱引用对象 |
虚引用 | 被GC Root间接引用的对象 | 必须配合引用队列使用 |
终结器引用 | 被GC Root间接引用的对象 | 必须配合引用队列使用 |
强引用:只要引用关系还在,就不会被垃圾回收器回收
Object obj = new Object();
软引用: 在将要抛出内存溢出之前,才对软引用进行回收。
public static void main(String[] args) {
SoftReference<String> string = new SoftReference<>("test");
System.out.println(string.get());
SoftReference<String>[] list = new SoftReference[4];
list[0] = new SoftReference<String>("s1");
list[1] = new SoftReference<String>("s2");
list[2] = new SoftReference<String>("s3");
list[3] = new SoftReference<String>("s4");
String s = list[0].get();
}
弱引用:可以理解成不可达,被扫描到也会被纳入被GC队列
WeakReference<String> string = new WeakReference<>("test");
虚引用:一个对象被关联为虚引用的唯一作用是在这个对象被回收的时候能够收到通知。
finalize方法
简而言之,finalize方法就是给对象一次机会,在被杀死之前看看能不能活下来。
GC方法
垃圾回收算法
标记-清除
标记的是可清除的对象
定义:标记清除算法顾名思义,是指在虚拟机执行垃圾回收的过程中,先采用标记算法确定可回收对象,然后垃圾收集器根据标识清除相应的内容,给堆内存腾出相应的空间
- 这里的腾出内存空间并不是将内存空间的字节清0,而是记录下这段内存的起始结束地址,下次分配内存的时候,会直接覆盖这段内存
缺点:容易产生大量的内存碎片,可能无法满足大对象的内存分配,一旦导致无法分配对象,那就会导致jvm启动gc,一旦启动gc,我们的应用程序就会暂停,这就导致应用的响应速度变慢
标记-整理
代价是时间
标记-整理 会将不被GC Root引用的对象回收,清除其占用的内存空间。然后整理剩余的对象,可以有效避免因内存碎片而导致的问题,但是因为整体需要消耗一定的时间,所以效率较低
复制
代价是空间
将内存分为等大小的两个区域,FROM和TO(TO中为空)。
- 先将被GC Root引用的对象从FROM放入TO中
- 再回收不被GC Root引用的对象。
- 然后交换FROM和TO。
这样也可以避免内存碎片的问题,但是会占用双倍的内存空间。
分代回收机制(对上面的融合)
分代回收算法实际上是把复制算法和标记整理法的结合并不是真正一个新的算法,一般分为:老年代和新生代,老年代就是很少垃圾需要进行回收的,新生代就是有很多的内存空间需要回收,所以不同代就采用不同的回收算法,以此来达到高效的回收算法。
- 新生代:由于新生代产生很多临时对象,大量对象需要进行回收,所以采用复制算法是最高效的。
- 老年代:回收的对象很少,都是经过几次标记后都不是可回收的状态转移到老年代的,所以仅有少量对 象需要回收,故采用标记-清除或者标记-整理算法。
新生代中 Eden : SurvivorFrom : SurvivorTo = 8 : 1 : 1
1.Eden区
Eden区位于Java堆的年轻代,是新对象分配内存的地方,由于堆是所有线程共享的,因此在堆上分配内存需要加锁。而Sun JDK为提升效率,会为每个新建的线程在Eden上分配一块独立的空间由该线程独享,这块空间称为TLAB(Thread Local Allocation Buffer)。在TLAB上分配内存不需要加锁,因此JVM在给线程中的对象分配内存时会尽量在TLAB上分配。如果对象过大或TLAB用完,则仍然在堆上进行分配。如果Eden区内存也用完了,则会进行一次Minor GC(young GC)。
2.Survival from/to
Survival区与Eden区相同都在Java堆的年轻代。Survival区有两块,一块称为from区,另一块为to区,这两个区是相对的,在发生一次Minor GC后,from区就会和to区互换。在发生Minor GC时,Eden区和Survivalfrom区会把一些仍然存活的对象复制进Survival to区,并清除内存。Survival to区会把一些存活得足够旧的对象移至老年代。
3.老年代
老年代里存放的都是存活时间较久的,大小较大的对象,因此年老代使用标记整理算法。当老年代容量满的时候,会触发一次Major GC(full GC),回收老年代和年轻代中不再被使用的对象资源。
回收流程
1、新创建的对象都被放在了新生代的伊甸园Eden中
2、当伊甸园中的内存不足时,就会利用可达性分析算法,进行一次垃圾回收。这时的回收叫做 Minor GC
- Minor GC 会将伊甸园和幸存区FROM存活的对象先移到 幸存区 TO中
- 并让其寿命加1
- 再交换两个幸存区
- HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90 %。如果每次回收有多于 10% 的对象存活,那么当一块 Survivor 空间不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间
3、再次创建对象,若新生代的伊甸园又满了,则会再次触发 Minor GC(会触发 stop the world, 暂停其他用户线程,只让垃圾回收线程工作)
- 这时不仅会回收伊甸园中的垃圾,还会回收幸存区中的垃圾
- 再将活跃对象复制到幸存区TO中。
- 回收以后会交换两个幸存区,并让幸存区中的对象寿命加1
4、新生代晋升机制:如果幸存区中的对象的寿命超过某个阈值(最大为15,4bit),就会被放入老年代中
5、如果新生代老年代中的内存都满了,就会先触发Minor GC,再触发Full GC,STW时间更长。扫描新生代和老年代中所有不再使用的对象并回收
- 新生代:由于新生代产生很多临时对象,大量对象需要进行回收,所以采用复制算法是最高效的。
- 老年代:回收的对象很少,都是经过几次标记后都不是可回收的状态转移到老年代的,所以仅有少量对 象需要回收,故采用标记-清除或者标记-整理算法。
大对象处理策略
当遇到一个较大的对象时,就算新生代的伊甸园为空,也无法容纳该对象时,会将该对象直接晋升为老年代
线程内存溢出
某个线程的内存溢出了而抛异常(out of memory),不会让其他的线程结束运行
这是因为当一个线程抛出OOM异常后,它所占据的内存资源会全部被释放掉,从而不会影响其他线程的运行,进程依然正常
Hotspot一些算法
根节点枚举
必须stop the world,影响性能
安全点
解决枚举阶段暂停时间长的问题,但存在有些线程不执行,无法到达安全点的问题
安全区域
解决上面这个问题,记忆集,解决枚举所有root的问题
写屏障
解决如何维护记忆集的问题
并发可达性分析(三色)
「白色」:该对象没有被标记过。(对象垃圾)
「灰色」:该对象已经被标记过了,但该对象下的属性没有全被标记完。(GC需要从此对象中去寻找垃圾)
「黑色」:该对象已经被标记过了,且该对象下的属性也全部都被标记过了。(程序所需要的对象)
三色标记存在问题
- 浮动垃圾:并发标记的过程中,若一个已经被标记成黑色或者灰色的对象,突然变成了垃圾,由于不会再对黑色标记过的对象重新扫描,所以不会被发现,那么这个对象不是白色的但是不会被清除,重新标记也不能从
GC Root
中去找到,所以成为了浮动垃圾,浮动垃圾对系统的影响不大,留给下一次GC进行处理即可。 - 对象漏标问题(需要的对象被回收):并发标记的过程中,一个业务线程将一个未被扫描过的白色对象断开引用成为垃圾(删除引用),同时黑色对象引用了该对象(增加引用)(这两部可以不分先后顺序);因为黑色对象的含义为其属性都已经被标记过了,重新标记也不会从黑色对象中去找,导致该对象被程序所需要,却又要被GC回收,此问题会导致系统出现问题,而
CMS
与G1
,两种回收器在使用三色标记法时,都采取了一些措施来应对这些问题,CMS对增加引用环节进行处理(Increment Update),G1则对删除引用环节进行处理(SATB)。
常见垃圾收集器
一个虚拟机并不是只有一种垃圾回收算法,往往是多种垃圾回收算法共存。
Serial
- serial工作在新生代
- 顾名思义,就是在执行GC的时候,整个java程序只有Serial线程在执行。
- 采用复制算法
- 优点
- 内存开销很少
- 在CPU核心比较少的处理机上,效率高,
- 适合运行在客户端
Parnew
- serial的多线程版本,工作在新生代
- 支持并行收集
- 在单核处理器下,效率不见得比serial高
Parallel scavenge
- 和parnew差距不是很大,也是工作在新生代,主要体现在以下几个方面
- Paralle 提供了几个参数,让回收器有了更多灵活性
- -XX:MaxGCPauseMillis,最大暂停时间,如果设置的时间很小,那么新生代的大小也会很小,这也会造成GC很频繁。
- -XXGCTimeRatio,系统吞吐量,指的是非GC时间占(GC+非GC)时间的比例
- -XX:+UseAdaptiveSizePolicy,是一个激活开关,当开启后,不需要指定新生代大小及比例,晋升老年代对象的大小等参数,系统根据当前的资源使用情况动态调节这些参数
Serial Old
- Serial的老年代版本
- 采用标记整理法。
Paralle Old
- Parallel scavenge的老年代版本
CMS(Concurrent Mark Sweep)
- 基于标记清除法
- 针对老年代
- 会分成四个阶段
- 初始标记,会stop the world,只标记GCRoot直接关联的对象,
- 并发标记
- 重新标记,修正并发标记阶段,由于用户线程工作而产生变化的对象
- 并发清除
- 停顿时间短
- 如果剩下的空间不足以分配新对象,就会并发失败,然后启用serial old,然后就会stw时间变长。
- 其次,因为CMS是标记清除法,所以会存在大量内存碎片,如果内存碎片过多,无法创建新对象,又会导致Full GC
- 浮动垃圾:指的是在并发标记和并发回收的时候系统产生的垃圾
G1(garbage first)
- 在G1中,把内存区域分成相等的大小(称之为region),每个region的大小通常为1~32M(2的N次幂),具体为多大通过-XX:G1HeapRegionSize确定
- 每个region可以充当四种区域中的一种。(特别说明:引入了大对象区域,超过了region大小一半的对象被认定为大对象,G1的大多数行为把大对象区域和老年代一样看待。
- GC时面向的对象不再像以往那样针对新生代或者老年代或者整个堆区,在G1中,针对的某些region。
- 怎么确定具体要GC的是哪些Region,GC线程会去追踪每个region的垃圾堆积价值,也就是我如果gc这个region,我能获得多大的内存释放,根据这个价值大小形成一个优先级列表,当需要触发GC时,就优先GC高优先级的。
- 由于GC的最小单位为一个region,那么跨region的对象引用是怎么高效的被识别是否应该被GC?每个region都有一个记忆集,本质上他是一个hash表,key为引用此region中对象的region地址,value为卡表索引号的集合。
工作流程
**初始标记(Initial Marking):**这阶段仅仅只是标记GC Roots能直接关联到的对象并修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确的可用的Region中创建新对象,这阶段需要停顿线程,但是耗时很短。而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
**并发标记(Concurrent Marking):**从GC Roots开始对堆的对象进行可达性分析,递归扫描整个堆里的对象图,找出存活的对象,这阶段耗时较长,但是可以与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
**最终标记(Final Marking):**对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
**筛选回收(Live Data Counting and Evacuation):**负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划。可以自由选择多个Region来构成会收集,然后把回收的那一部分Region中的存活对象==复制==到空的Region中,在对那些Region进行清空。由于涉及到存活对象的移动,所以必须stop the world,
四个阶段中,只有并发标记不需要stop the world
G1在整体上的垃圾回收算法可以看成一个标记整理法,因为每次释放空间都是释放的一个个完整的region。
在局部上(两个region之间),可以看成复制算法,因为某一个region没有被gc的对象会被复制到另一个region。
类加载过程
下面展示了类的生命周期
关于在什么情况下需要开始类加载过程的第一个阶段“加载”,《Java虚拟机规范》中并没有进行 强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,《Java虚拟机规范》 则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之 前开始):
- 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始 化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
- 使用new关键字实例化对象的时候。
- 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外) 的时候。
- 调用一个类型的静态方法的时候。
- 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需 要先触发其初始化。
- 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先 初始化这个主类。
- 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解 析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
- 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有 这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
加载
- 通过一个类的全限定名来获取定义此类的二进制字节流(.class文件)。这个阶段是由类加载器实现的。
- 通常情况下是本地的.class文件,但是实际上可以从很多地方获得比如说压缩包,网络中,运行过程中动态生成的代理类,数据库中获取的等等。
- 补充:数组的类加载,数组本身不通过类加载器创建,而是jvm直接在内存中构造出来的。但是如果数组的元素是某一种对象(非数组),他的元素还是依靠类加载器加载。这个数组会和它的元素的类加载器进行关联,如果数组存储是基本变量,那么数组将会和引导类加载器关联。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。(具体怎么存储跟具体的虚拟机实现有关。
- 在内存(堆区)中生成一个代表这个类的java.lang.Class对象,作为程序访问方法区中的类型数据的外部接口。
连接
验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
- Java语言本身能够起到一定的验证作用,什么意思呢?指的是javac编译器在将.java文件编译成.class文件的时候就能够起到一定的验证作用,但是我们的.class文件并不一定只能由javac编译而来,所以.class仍然存在威胁jvm安全的可能性。
- 验证的内容主要有以下几个部分:文件格式验证,元数据验证,字节码验证,符号引用验证。
准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量,一定要注意,得有static,如果没有static,那肯定只有创建实例的时候才有值,而且也不要和方法里的变量混淆,方法根本就不谈内部有什么静态定义的东西)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK 7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。
- 上面的初始值,通常情况下是零值
- 如果一个变量声明为static final,那么在准备阶段就不再是0值,而是具体的值
public class test {
static int i;
public static void main(String[] args) {
System.out.println(i);
}
}
// 运行结果是 0
public class test {
public static void main(String[] args) {
int i;
System.out.println(i);
}
}
// 报错 java: 可能尚未初始化变量i
解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程
初始化
初始化阶段就是执行类构造器<clinit>()方法的过程。<clinit>()并不是程序员在Java代码中直接编写 的方法,它是Javac编译器的自动生成物,
- <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的 语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问 到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访 问,
public class Test{
static{
i = 0; // 可以赋值
System.out.print(i); // 不能访问
}
static int i = 1;
}
类加载器
Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节 流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。(类加载器也是JVM的一部分,虽然它放在了JVM外部实现)
类与类加载器
-
只有class文件的来源一致并且加载它们的类加载器一致,才能认定两个类是一致的。
-
在JVM的视角,类加载器只有两种,bootstrap calssloader(在hotspot中由C++实现)和其他class loader(由Java实现)
-
在程序员的视角,类加载器分为三层(四层),绝大多数java类都会由以下三种加载器进行加载
-
Bootstrap Class Loader,主要装载<JAVA_HOME>\lib下的类
-
Extension Class Loader,主要装载<JAVA_HOME>\lib\ext下的类
-
Application Class Loader,主要装载类路径(classpath)下的类和用户的自定义类,如果用户没有自己实现类加载器,默认采用此加载器
- Classpath,它本质上是一组目录的集合,在进行类加载的时候,Application Class Loader的加载范围就是这些目录下的类。下面图中的classes文件夹。
关于classpath
对于SpringBoot项目来说,classpath
指的是src.main.java和src.main.resources
路径以及第三方jar包的根路径,存放在这两个路径下的文件,都可以通过classpath
作为相对路径来引用。更加准确的说法是:classpath代表着target下的classes,而target/classes也等同于src/mian/java和 src/main/resources
双亲委派模型
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
类加载器类加载源码
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 1. 检查是否已经加载,跟进去调用的是一个native方法
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 如果有父类,直接委托给父类加载器
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 没有父类加载器,直接交给bootstrap加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
// 如果父类和bootstrap都没法加载,自己才尝试加载
if (c == null) {
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
优点:**避免某一个类被重复加载,**导致JVM中存在某一个类被不同的类加载器加载,形成多个不同的类(实际上是一份class文件),因为类是否相同是由class文件和加载它的类加载器共同决定的
打破双亲委派模型
例如tomcat,Tomcat给每个 Web 应用创建一个类加载器实例(WebAppClassLoader),该加载器重写了loadClass方法,优先加载当前应用目录下的类,如果当前找不到了,才一层一层往上找,这样就做到了Web应用层级的隔离。
JDK9模块化系统
模块化什么意思?
为了能够实现模块化的关键目标——可配置的封装隔离机制
解决了什么问题
- 首先要解决JDK 9之前基于类路径(ClassPath)来查找依赖的可靠性问题。此前,如果类路径中缺失了运行时依赖的类型,那就只能等程序运行到发生该类型的加载、链接时才会报出运行的异常。而在JDK 9以后,如果启用了模块化进行封装,模块就可以声明对其他模块 的显式依赖,这样Java虚拟机就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否 完备,如有缺失那就直接启动失败,从而避免了很大一部分[1]由于类型依赖而引发的运行时异常。
- 可配置的封装隔离机制还解决了原来类路径上跨JAR文件的public类型的可访问性问题。JDK 9中 的public类型不再意味着程序的所有地方的代码都可以随意访问到它们,模块提供了更精细的可访问性控制,必须明确声明其中哪一些public的类型可以被其他哪一些模块访问,这种访问控制也主要是在类 加载过程中完成的,具体内容笔者在前文对解析阶段的讲解中已经介绍过。
- 减少环境资源的开销和降耦合度:在JDK 9之前每次启动JVM都要耗费至少 30MB~60MB的内存空间,其主要原因是JVM需要加载rt.jar,不管是否用到其中的类是否被加载,第一步就是要将整个rt.jar加载到内存中去,这样极大的浪费了内存空间,而JDK 9开始就可以选择性的模块进行加载。—–知乎
模块化下的类加载器
.png)
当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器 完成加载,也许这可以算是对双亲委派的第四次破坏。
虚拟机字节码执行引擎
运行时的栈帧结构
(一个线程用一个栈,线程执行时,一个栈帧压入弹出对应一个方法的进入和返回)
每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。 在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算 出来,并且写入到方法表的Code属性之中[2]。换言之,一个栈帧需要分配多少内存,并不会受到程序 运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式
局部变量表
局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义 的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方 法所需分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Variable Slot)为最小单位,一个变量槽占多大空间根据虚拟机确定。不过每个变量槽都应该能存放一个boolean、 byte、char、short、int、float、reference或returnAddress类型的数据。
Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数量。
当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程, 即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。
为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变 量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。
public static void main(String[] args)() {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
// 至此,placeholder的槽位已经被a重用了
int a = 0;
System.gc();
}
操作数栈
操作数栈(Operand Stack)是一个后进先出栈。**同局部变量表一样,操作数栈的最大深度也在编译阶段写入到 Code 属性的 max_stacks 数据项中。**操作数栈的每一个元素可以是任意的 Java 数据类型,包括 long 和 double。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。在方法执行的任何时候,操作数栈的深度都不会超过 max_stacks 数据项中设定的最大值。
一个方法刚开始执行的时候,该方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈和出栈操作。
动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。Class 文件的常量池中存在大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数,这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化成为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。
一种是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层方法的调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口。
另一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是 Java 虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。这种称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给上层调用者产生任何返回值的。
无论采用何种退出方式,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上次方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,成为栈帧信息。
方法调用
分派
静态分派
用于重载。
重载的方法选择,看的是参数的静态类型,而不是实际类型。
动态分派
首先明确:在创建子类对象的时候会调用父类的构造函数。当一个类继承了其它类时,在它的构造函数(constructor)中super()
必须被首先调用,如果super()
没有被调用,则编译器将在构造函数(constructor)的第一行插入对super()
的调用。这就是为什么当创建一个子类的对象时会调用父类的构造函数(constructor)的原因。
(87条消息) 为什么在创建子类对象的时候会调用父类的构造函数_不务正业的野猴子的博客-CSDN博客_new一个子类的新对象会调用父类的构造函数吗
/**
* 字段不参与多态
* @author zzm
*/
public class FieldHasNoPolymorphic {
static class Father {
public int money = 1;
public Father() {
money = 2;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Father, i have $" + money);
}
}
static class Son extends Father {
public int money = 3;
public Son() {
money = 4;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Son, i have $" + money);
}
}
public static void main(String[] args) {
Father gay = new Son();
System.out.println("This gay has $" + gay.money);
}
}
最后打印出的结果是下,输出了两句I am Son. 那是因为 Son 类创建的时候,隐式调用了Father的构造函数。然而在Father构造函数中,使用showMeTheMoney()是一次虚方法调用,实际执行的是Son::showMeTheMoney()方法。而此时,(有小伙伴可能会问,为什么不是打印3呢,那是因为,子类的money在类加载的准备阶段时,被赋予了初始零值,类加载是不会为非final属性赋非零值的。而此时,子类里面的属性还没有被初始化,子类属性的初始化是随着子类构造器而一起进行的,而现在进行的是父类的构造,只有当父类构造器运行结束返回以后,才算子类构造器运行,这时,也才会有money=3的初始化。). 后面子类自己再打印4,以及sout里头通过静态类型Father,访问到了父类的money属性
I am Son, i have $0
I am Son, i have $4
This gay has $2
基于栈的解释执行引擎
先从指令集感受一下
基于栈的指令集
iconst_1
iconst_1
iadd
istore_0
基于寄存器的指令集
mov eax, 1
add eax, 1
栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢一些,所有主流物理机的指令集都是寄存器架构也从侧面印证了这点。不过这里的执行速度是要局限在解释执行的状态下,如果经过即时编译器输出成物理机上的汇编指令流,那就与虚拟机采用哪种指令集架构没有什么关系了
JIT编译器(just in time 即时编译器)
,当虚拟机发现某个方法或代码块运行特别频繁时,就会把这些代码认定为(Hot Spot Code 热点代码
,为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码
,并进行各层次的优化
,完成这项任务的正是JIT编译器
。作者:我们都很努力着 链接:https://www.jianshu.com/p/ae0d47e770f0 来源:简书 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
JIT编译
对于 Java 代码,刚开始都是
被编译器编译成字节码文件
,然后字节码文件会被交由 JVM 解释执行
,所以可以说 Java 本身是一种半编译半解释执行的语言
。当JIT编译启用时(默认是启用的),JVM读入
.class文件解释后
,将其发给JIT编译器
。JIT编译器将字节码编译成本机机器代码
。通常
Javac将程序源码编译
,转换成java字节码
,JVM通过解释字节码将其翻译成相应的机器指令
,逐条读入,逐条解释翻译。 经过解释运行,其运行速度必定会比可运行的二进制字节码程序慢。为了提高运行速度,引入了JIT技术。在执行时JIT会把翻译过的机器码保存起来,已备下次使用,因此从理论上来说,采用该JIT技术能够,能够接近曾经纯编译技术。
现在主流的商用虚拟机(如Sun HotSpot、IBM J9 如 # 各大主流的虚拟机比较 )中
几乎都同时包含``解释器
和编译器
(三大商用虚拟机之一的JRockit是个例外,它内部没有解释器,因此会有启动相应时间长之类的缺点,但它主要是面向服务端的应用,这类应用一般不会重点关注启动时间)。 二者各有优势:当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译的时间,立即执行;当程序运行后,随着时间的推移,编译器逐渐会返回作用,把越来越多的代码编译成本地代码。
整体对代码进行转换,称之为编译;一句一句的转化,称之为解释。
关于Java解释执行和编译执行,Java既存在解释执行也存在编译执行(即时编译)。
- 解释执行什么意思呢?字节码被解释器一句一句的解释为机器码交给执行引擎执行。
- 编译执行什么意思呢?
- 某些高频字节码被即时编译器为机器码之后被缓存起来,下次执行到相关区域的时候直接执行机器码(即时编译)
- 字节码被解释器整体的编译为机器码交给执行引擎执行。(静态编译)
- Java作为一种解释性语言,但是并不是说Java的所有代码都是解释执行,JIT(即时编译器)就可以将热点代码(字节码)直接转化成机器码。
- 热点代码究竟是什么呢?
- 被多次调用的方法
- 被多次执行的循环体、
- 不管是上面两种中的哪种,进行编译的都是方法(循环体所在的方法)
- 热点代码究竟是什么呢?
- 怎么理解这个将字节码转换成机器码的编译器?在将源代码转换成字节码的时候不是已经编译了一次吗?怎么这里还有一个叫即时编译器的东西对字节码进行编译。
- 如果是解释执行,程序运行是要经过一阶段的编译和二阶段的解释
- 如果是编译执行,会经过一阶段的编译和二阶段的编译(这里就是即时编译)
- 为什么一个叫解释,一个叫编译呢?正是因为JIT是整体对代码进行转换,所以称之为编译;一句一句的转化,所以称之为解释。
前端编译优化
语法糖
泛型
编译前的
public static void main(String[] args) {
Map<String, String> map = new HashMap<String, String>();
map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println(map.get("hello"));
System.out.println(map.get("how are you?"));
}
编译后的
public static void main(String[] args) {
Map map = new HashMap();
map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println((String) map.get("hello"));
System.out.println((String) map.get("how are you?"));
}
可以看到在编译后的代码中实际上并不存在什么泛型,都是裸类型(泛型擦除)。在操作具体的类型的时候是通过强制转换实现的。
自动拆箱装箱、增强for
编译前
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4);
int sum = 0;
for (int i : list) {
sum += i;
}
System.out.println(sum);
}
编译后
public static void main(String[] args) {
List list = Arrays.asList( new Integer[] {
Integer.valueOf(1),
Integer.valueOf(2),
Integer.valueOf(3),
Integer.valueOf(4) });
int sum = 0;
for (Iterator localIterator = list.iterator(); localIterator.hasNext(); ) {
int i = ((Integer)localIterator.next()).intValue();
sum += i;
}
System.out.println(sum);
}
变长参数
在底层变长参数实际上是一个数组
字符串的+
在jdk5及之前,底层是通过StringBuffer(线程安全)实现,在Jdk6及以后,是通过StringBuilder(线程不安全)实现的