0%

重读了一遍《深入理解java虚拟机》, 发现第一遍读类文件相关内容的时候,真的是囫囵吞枣,很多细节都被我跳过了,无论是符号引用的含义,还是属性表的理解,都没有弄懂,当时想着“反正也用不到,跳过吧”,却没注意到他们包含了许多java底层实现的核心原理。

看来经典书籍要多读多总结,是有道理的。

于是在阅读这个章节时,用processorOn做了一副超大的类文件解析图,方便自己通过浏览这个图能马上回忆起class文件的结构以及内部的指令。
下面的内容是拆分后的内容,对于每块拆分的内容,会有详细的解释。
对于完整大图,我放在文末,需要收藏的可以自取

好了下面我们开始,文章内容较长,建议收藏一下分时段阅读。

[toc]

魔数、版本号#

image.png

  • 每类文件都有一个魔数,用于快速校验文件类型。
  • 对于高低版本号,只要明确java11\java8这种版本是主版本号
  • 永远向下兼容, 即高版本jvm可以读取低版本的class文件, 但是低版本的jvm无法读取高版本的class文件

常量池(常量池个数、多个常量项)#

image.png
大部分文件协议格式中,都会先给定一个某项的数量长度,再决定某项的个数,方便确认遍历几次才结束。常量池的设置也是这个原理。
因此学习java的class格式,对我们设计某些文件格式或者协议都是一种不错的借鉴。


常量池中的常量到底是干嘛的?和我们理解的static final String xxx常量是一个意思吗?#

不对!代码中定义的final类型字符串常量只是一种用途。更重要的一种用途是符号引用。
而对符号引用的理解,是对java类文件原理最难也最重要的地方。
直接去解释符号引用的话,还是很难理解的,因此我们按下不表,在第4部分“类索引”部分会给出详细解释。


常量池的索引计数为什么从1开始(即其他地方要使用常量池的第一个常量时,必须写成1而不是0)?#

因为要留一个0,表示不引用任何常量

  • 举例:匿名类就是没有名字的,但是类文件结构中,类名那边总需要填入类名常量索引,因此可以填入0,表示“没有类名”的意思。
  • 再来一个例子:object类,是没有父类的,所以他的父类那一栏填的常量索引也是0
  • 对于常量池的作用,后面会有更详细的体现和解释。

类定义的第一行(类访问标志、本类、父类、实现接口)#

image.png

为什么叫类定义的第一行,因为这就来自我们写每个类时的第一行内容。
例如
public abstract class A extend B implement C,D
这句话对应的所有信息就包含在了上图中,因此我叫他“类定义的第一行”


CONSTANT_class_info这个类常量到底是干嘛的?
从图上可以看到,他其实就是指向了一个表示类名的字符串常量。
这里也可以看到,java文件中的所有名称例如类名、方法名、字段名,都会以Utf_info的形式,存储在常量池中。
image.png


为什么要这样多走一层?为什么不能直接指向一个字符串常量?#

这个问题我没找到解释,但可以理解为这是最基础的一层封装。

image.png


字段表(字段数量,各字段(修饰符、名、类型、属性))#

image.png


可以看到,字段名、字段类型分别对应了2个字符串常量。
特别注意字段类型使用一个字符串来表示的,而不是一个constant_field_info。
那么constant_field_info是干嘛的呢?


字段修饰符中的synchetics指的是编译器自动生成的字段,怎么理解呢?什么情况下会用到?#

找到一个简单的例子(代码出处:知乎-不凋花),用枚举做switch:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum Foobar {
FOO,
BAR;
}
class Test {
static int test(Foobar var0) {
switch (var0) {
case FOO:
return 1;
case BAR:
return 2;
default:
return 0;
}
}
}

switch的原理,我们应该很容易想到,就是做一次顺序检查,那么检查时,肯定程序里需要有一个列表吧,因此上面switch的背后逻辑代码是长这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Test$1 {
static final int[] $SwitchMap$Foobar;
static {
$SwitchMap$Foobar = new int[Foobar.values().length];
try {
$SwitchMap$Foobar[Foobar.FOO.ordinal()] = 1;
} catch (NoSuchFieldError e) {
;
}
try {
$SwitchMap$Foobar[Foobar.BAR.ordinal()] = 2;
} catch (NoSuchFieldError e) {
;
}
}
}

可以看到有一个“static final int[] $SwitchMap$Foobar;”, 这个静态数组字段,就是编译器帮忙生成的字段,他会被标记成synchetics


上面可以看到每个字段项的最后包含属性数量和属性长度,那么class中的属性和上面的“字段名”、“字段类型”有什么区别呢?#

属性是可有可无的,而且提供了高度的“jvm可扩展性”。
换言之,在jvm虚拟机规范中,“字段修饰符”、“字段名”、“字段类型”都是必备的,而属性则没有限制。
因此我们甚至可以自己实现一个虚拟机,定义新的属性,在class中加上属性项然后自己使用

对于属性作用的更详细理解,可以看后面的方法章节,方法中的属性是比较重要且用得最多的。


从字段属性可以看到, 类似于static final int a =10这种常量,就是通过属性里的constant属性来设置的。

有个泛型签名的属性,可能不太好马上理解,后面在方法章节中会一并提到这个属性的作用!

方法表(方法数量、方法项(修饰符、名、描述、属性))#

image.png
class文件中,最值得学习的就是常量池和方法表了!


方法修饰符中的桥接#

对于方法修饰符,大部分都很好理解,有2个修饰符需要关注:“bridge”和“synthetic”。

其实很多bridge桥接方法本身也是synthetics系统生成的,所以我不太想去区分二者,只要关注他们2个用来做什么。

思考下面这个问题:
1. 假设有个非公开的类A,A中有个public方法f(),有个继承自A的公开类B,没有重写f(),那么外部是否可以调用b.f()?

1
2
3
4
5
6
7
8
9
10
11
private static class A {
f() {..}
}

public static class B extend A{
// 不重写任何方法
}
public static void main(String args[]) {
B b = new B();
b.f();
}

我们很容易可以得出b.f()可以调用的结论。
但由于B没有重写f(), 所以对于编译后的B.class而言,这意味着不会在class文件中包含f方法。
那么当执行f时,通过多态,会定位到A.f(),此时A是非公开的类,权限就会出错,因为不允许直接引用非公开的类的方法,只能间接使用。

如何解决?要修改多态的动态分派校验机制吗?

不需要,编译器为了方便,直接为我们在B中重写了f()来间接调用父类方法,类似于

1
2
3
public void f() {
super.f()
}

这样的话就不用担心外部调用者没有权限使用A.f()了。

2. 有个泛型基类Base<T>,包含一个方法f(T t), 有个子类Sub<String>, 实现了方法f(String s), 两个f方法的入参并不一致,为什么还多态的机制还能生效?

1
2
3
4
5
6
7
class Base<T> {
f(T t);
}

class Sub extend Base<String>{
f(String t);
}

这2个方法的入参确实不同, 前者的方法签名是f(Ljava/lang/Object;)V, 后者是f(Ljava/lang/String;)V。 多态(动态分派)的规则也没有变,确实是要求入参一致。
因此编译器为Sub类自动生成了一个f(Ljava/lang/Object;)V,代码如下:

1
2
3
public void f(Object o) {
this.f((String)o);
}

这样多态的机制也能实现了。
可以看到这一切都是为了适配多态,同时避免过多的特殊逻辑,因此使用桥接方法,来生成了我们看不到的重写方法


从下面可以看到, 方法描述符是一个**包含“入参和返回值”**的描述符
image.png

因此,java是允许 同入参、同方法名、不同返回值的方法存在于同一个class文件中的。

这是不是有点反常识?这种情况我们好像编写不出来的,编译器不会通过!

其实这也是桥接+自动生成才会有这种情况。
前文的泛型例子,用泛型T做入参,会生成一个桥接方法,和父类的匹配。

那么如果泛型T是一个返回值呢:

1
2
3
4
5
6
7
class Base<T> {
T f();
}

class Sub extend Base<String>{
String f();
}

那么也是一样的道理,桥接了一个父类的f方法,但仅仅是返回值不同而已。所以会出现只有返回值不同的方法。


方法表的属性和字段的属性类似, 也是属性数量 + N个属性项。
但是方法表属性里的干货就更多了!

属性的结构#

之前字段属性中没提到属性到底长啥样,以方法中的throws异常属性为例,:
image.png

从这里可以看到,每个属性都有个属性名,和常量不同,区分不同常量用的是1个2字节的数字,而属性则是用一个字符串来表示。
这样的区别就是因为常量个数有限,而属性为了扩展性,不能存在数量限制。

另外从这也可以知道, 我们在方法名上写的f() throws IOException 都是存在于异常属性中的。


最关键的Code属性#

Code属性是方法属性中最最最重要的属性。
他告诉我们编译器是怎样将我们的文本代码封装成一个class文件的。
首先,code属性的属性名就是一个“Code”
image.png

操作数栈、局部变量表大小、指令码数量#

接着会包含3个重要的内容:max_stack、max_local和code_length
image.png
从max_stack和max_local我们可以看到,操作数栈和局部变量表的大小,已经在class文件中计算出来了,因此当开辟一个新的栈帧时,jvm便能够知道给这个方法开辟多大的空间,不用担心栈上分配不够的问题。
注意,是操作数栈的大小,而不是程序执行的栈的深度,程序可没法感知我们能够递归多少次。


指令码解读#

code_length代表了我们这个方法在编译后,有多少条字节码指令,而后面紧跟着的,就是对应数量的java字节码指令了。
image.png

指令码种类非常多,这里只列举关键的一些信息。

数据计算用的指令码#

首先,每种涉及基本数据类型的计算指令,都会在指令最前方,携带一个T,如图:
image.png
里面有句话:“不是每种数据类型和每个操作都有指令对应(否则数量太多)”
这句话怎么理解呢,可以结果图上右侧的表格,从而得知,有些指令是不包含所有类型的,所以可能会借用一些的技巧,比如把byte、short都视为int在操作上去操作。

对象操作的指令码#

另一个类指令码是和对象操作有关,例如:
image.png
可以看到,当试图获取一个类字段时,他指向的是一个class_field_info常量索引,这个常量会提前被放进class文件的常量池中。


为什么对象操作指令码中只包含了类引用和名称呢,我怎么知道我调用的是哪个对象的字段?#

A: 你要调用的对象,已经通过前面提到的操作数栈相关指令,把引用放到了操作数栈的第一个,因此,jvm只要取栈顶对象,然后根据名字进行字段操作即可,后面的方法调用也是一样的道理。


new对象和new数组,用的是2个不同的指令,为什么要有区分?不能把数组当成一个java对象吗#

这要从对象的内存结构,以及类加载机制上去思考。
因为数组的对象头,和普通对象的对象头是不一样的。

  • 数组的对象头中包含了数组长度,而普通对象没有
  • new一个数组时,数组中包含的类并不会做类加载。
    有这么多区别,肯定是新增一个单独针对数组的指令来处理,要简单很多

操作数栈指令(pop等)#

image.png
其他指令好理解, 但操作数栈指令有个dup_x指令,例如dup1_1 就是复制栈顶并再放入1个。为什么需要这么一个指令?

其实当我们调用 A a = new A()时,这一句话生成的指令中就包含了dup指令
因为当我们new出1个A引用时,它有两件事要做:

  1. 调用A的构造函数。
  2. 把引用地址赋值给a这个局部变量
    而每件事都会消耗一个A的引用!所以才需要赋值。
    因此可以看到,指令码很多时候都是基于操作数栈进行操作的,每操作一个数据或引用,就消耗一个

方法调用指令(invokevirtual等)#

image.png
对于方法调用指令,和前面的类字段调用有点像,也是一个方法常量,方法常量包含类索引和方法描述索引。
对于方法究竟是如何触发调用实现多态的、invokevirtual指令和invokedynamic指令有什么区别,这个内容就更多了,后面我会放到类加载的图解笔记中讲解。

异常表属性#

指令码结束后,后面会紧跟着一个异常表。表中的每一行长这样:
image.png
是不是恍然大悟,原来try-catch代码的逻辑在这边, 它本质上就是抛异常时,根据try的位置和异常类型,这个异常表中进行查找到对应的catch代码位置,从而实现异常处理。


那finally的操作被放到哪了?catch操作完了之后,它怎么知道要跳转到哪里?#

finally模块在java语言中是必须执行的,在编译的时候,通过将finally中代码块分别在try模块的最后和catch模块的最后都复制了一份,通过这样来保证finally的必定执行


对于synchronized关键字,它本质是生成了monitorenter和monitorexit两个指令(上面方法调用指令里的最后2个)。但如果发生了异常,那会不会无法monitorexit了?#

生成code字节码时,jvm会自动为synchronized生成1个默认的异常表和throw指令,保证中间同步块发生异常时,monitorexit能够正确被指令(类似于放了一个自动生成的try-catch代码,或者在已有的catch操作后添加)。


前面提到方法属性中,已经有一个名叫“Exception”的属性,和这个code属性中的异常表有什么区别?#

上面code异常表指的是代码执行时try-catch的逻辑部分
而方法中的exception属性则是方法名上所声明的throws异常。

Code的扩展属性#

在code属性中,竟然还携带了属性,也就是说,是允许“属性中的属性”。毕竟属性的实现是可以完全自定义的,那么自己给自己新增额外特性完全是允许的。
image.png
里面有个属性叫“局部变量描述属性”,长这样:
image.png
从这里,你就能明白,为什么你从IDEA里看到反解后的class文件,有时候是var1、var2之类莫名其妙的局部变量,有时候却又能看到完整的变量名了吧?就是通过这个属性决定的。毕竟存储局部变量名的代价还是很高的。

其他的方法属性#

image.png
泛型签名这个属性很迷惑,不是有泛型擦除吗,为什么还需要这个属性?
其实泛型签名属性是为了方便反射的。
我们通过前面关于桥接的原理,可以知道编译时会发生泛型擦除,方法入参都变成了object。
但是反射API可能希望获取泛型信息因此可通过这个扩展属性进行获取。所以会增加这个属性,从而能感知一些泛型属性相关的信息。

类属性#

既然方法和字段都有属性,那么类肯定也有属性:
image.png
其他属性都比较好理解或者不重要,重点讲一下内部类属性。
通过内部类属性,我们可以看到内部类并不是直接包含在这个class文件中,它其实是生成了另一个class文件,所以才需要一个内部类属性,来确认对应的名字,方便类加载时能找到内部类。


为什么内部类属性中,要包含宿主类的类名?难道宿主类,不就是它本身吗?#

因为,内部类中,还可以继续定义内部类


另外,从上面的一些属性中可以看到, 很多debug用的调试、展示信息,都会包含在class中
因此,当我们希望调试一些环境上执行的程序时,如果想提供最为贴近原代码,那就需要class文件中能有充足的信息,如果想要class文件小,那就去掉,具体怎么去掉或者添加,肯定就是一些编译选项的区别了。



最后的完整图#

好累,终于写完了,感觉能看到最后的人不会太多,但一通详细地分析和解决中间发现的问题,还是收获了不少。
最后贴上完整的大图,欢迎保存和收藏。
图片在线查看
https://www.processon.com/view/link/5d1a2d97e4b07c7231731b02

完整大图

[toc]

jdk动态代理#

实现jdk动态代理时需要哪些步骤?#

  1. 有一个目标类Class, 目标类必须implment某个行为接口Inteface
  2. 有一个代理类XxxProxy,实现自InvocationHandler接口
  3. 代理类必须实现一个invoke方法, invoke方法的入参是 被代理对象(目标对象)、执行方法(method的反射对象)、执行参数
    通过调用“ 执行方法.invoke(被代理对象,执行参数)” 这个常见的反射操作, 即可执行实际调用方法,然后你可以在这个方法的前后做各种处理或者改造。
  4. 使用Proxy.newProxyInstance(被代理类的类加载器, 被代理类的接口, 这个proxy代理对象)生成一个做过绑定的代理对象,能被调用的方法都是行为接口Inteface里的。

JDK动态代理的invoke原理是什么?#

proxy类里面真正的那个代理方法字节码类似如下(这个类就是给被别人调用的代理类,是newProxyxxx()操作后的那个类,不是invokeHandler类):

1
2
3
4
5
6
7
8
9
10
11
Proxy0.class
public final void sayHello(object[] args) {
try {
// this.h就是继承了invokeHandler的那个类对象
this.h.invoke(this, m3, args);
} catch(Excetion ...) {
}
}
static {
m3 = Class.forname("那个接口的类名").getMethod("sayHello");
}

m3就是通过static初始化阶段生成一个m方法, 然后调用sayHello时,调用invokeHandler.invoke,从而走进他里面实现的那个方法。

Proxy.newProxyInstance()会通过调用sun.misc.ProxyGenerator.generateProxyClass()来生成一个字节码,从而得到一个描述代理类的字节码数组。
生成字节码的过程就是根据class文件的格式规范去拼装字节码。

更多源码逻辑见Proxy源码解析


为什么Proxy.newProxyInstance必须要传入一个类加载器?#

48a3a4503f728f9ce9520a2c4820e9a9858c237a
因为他需要创建一个新的proxy类时,必须要基于接口去构造一个新的类对象,后面再使用类对象去反射一个实际代理对象
08a7c84b1abfab8a47fa544fd0de4603c1699e88


为什么jdk动态代理必须依赖一个接口?底层逻辑是什么?#

因为真正给用户调用的那个代理对象类XXXProxy$0, 实际上他为了做相关的代理操作(比如将inovkeHnandler作为成员,并调用各种字节码生产方法),需要extends Proxy这个类
也就是说他的类结构长这样:
5d5edf4370eff2c399096e1fefdd76e9cb7c90ee
因为java是单继承,导致父类已经被Proxy占用了,但你有需要对Worker对象做代理封装,并提供这个接口对外提供的方法,因此只能用implements的形式。

另一个更好的解释:

Cglib代理实际上是通过继承,也就是生成一个继承被代理对象的类,编译成class文件时还会额外生成一个fastclass文件
该文件记录各个method的class索引(类名+方法名+参数),当执行某个方法时,通过计算索引,定位到具体的方法,代理对象执行该方法,然后super调用父类(执行了被代理对象的方法)。
生成代理对象时通过fastclass索引机制直接定位到被代理对象的class文件,从而实现反复调用,等于说是class复用,每次都是直接拿被代理对象的class内容执行的。


那么spring里AOP所用的CGLIB为啥不需要接口呢?#

因为CGLIB是直接继承被代理类做字节码增加的,相当于做了字节码改造。

而jdk动态代理需要继承自Proxy类,利用父类的机制引用invokeHandler+反射的方法,来做代理操作。


那么CGLIB和jdk动态代理哪个更好?#

  1. 性能上比较
  • jdk动态代理生成类速度快,调用慢
  • cglib生成类速度慢,但后续调用快

但是实际上JDK的速度在版本升级的时候每次都提高很多性能,而CGLIB仍止步不前.
在对JDK动态代理与CGlib动态代理的代码实验中看,1W次执行下,JDK7及8的动态代理性能比CGlib要好20%左右。

JDK 动态代理和Cglib性能对比

  1. 稳定性上比较
  • jdk动态代理Java本身支持,不用担心依赖问题,随着版本稳定升级和优化。
  • 而CGLIB是外部技术,字节码库需要进行更新以保证在新版java上能运行
  1. 使用上比较
    jdk动态代理必须依赖接口,CGLIB不需要,在设计不当的历史包袱下
    如果必须对非接口对象做代理,那么只能用CGLIB临时过度。

因此spring实现AOP时,都是优先使用jdk动态代理,如果没有实现接口,才改成CGLIB过度,这也是为什么我们spring里的service类一般都要先定义1个接口,即使你只有1个service实现类。

[toc]


关于类初始化的时机和误区#

书籍的第一步部分上来就先讲了类初始化的时机,整理成图片如下:
image.png
看起来非常多,很难记住,很折磨。

个人认为,书籍把这一部分放到章节的最前面不太合理,曾经一度让我把上面的这些事件,理解成了类加载的时机,也不懂这些规则的缘由(根本原因还是此时读者对类加载的理解不够深。)

先贴一下类加载和类初始化的区别:

  • 类加载概念:将class文件加载到jvm中并生成class对象,并根据情况做初始化。
  • 类初始化概念:调用类class文件中默认存在的<cinit>类初始化方法。

而我们容易产生误解的原因,是因为书中没有这句话::所谓的类初始化时机,只是针对cinit类初始化方法的调用,并不是指的类加载时机!

以上图中红色的部分为例:
image.png
这里书籍中没有解释这3个规则的原因,在没理解原理前,强行记忆这3条是没有任何意义的。我认为是作者的失误。

在这里我挑其中一个做补充:
“使用类里的static final 常量,不会触发初始化”
想要理解这个规则,需要先理解class文件原理。
对于类的static final常量字段,它的常量值是存放在字段的constanValue属性中。
image.png
正因为如此,static final常量并不需要通过cinit方法中的指令来完成赋值。
所以也就没有必要在这时候调用<cinit>方法了。

因此对于“儿子类调用父类的静态成员,不用对儿子类做类初始化”也是一个道理,儿子类的类静态成员没有被使用到,没必要做cinit。

对于上面的分析,可以浓缩为一句话:
“如果我们急需使用static成员,且这个成员的值是要通过cinit方法赋值的,那么我们才做cinit初始化”

新的疑问:那为什么仅仅是new一个对象时,也一定要做cinit类初始化呢?
假设此时我还没用到static成员,那么new一个对象时,是否可以省去cinit,等用到静态成员的时候,再去触发cinit?

这涉及到了类初始化的另一个容易被忽视的点:“cinit类初始化方法,并不仅仅是做类成员的赋值,其实还可能包含一些初始化行为调用”,这可以是资源的启动或者加载等类对象必须要用到的内容。

因此在一切可能触发类对象实际行为前,必须触发cinit避免出错。

所以刚才的长篇大论,可以再次进行优化,浓缩为:
“当需要用到static成员的初始赋值,或者对类对象进行正式使用时,才会触发cinit类初始化,目的是为了保证类对象或者类成员的正确使用”
拿着这一句话,去回看前面的类初始化时机的触发时机和不触发的时机时,相信你就会有更深的理解了,甚至也不需要强行去记忆每一条规则了。


有误导的“加载三部曲”#

有一个很经典的回答,叫做类加载三部曲:加载、连接、初始化
好像类加载过程就是这三步按照顺序串行拼装起来的。

实际上这3个过程是存在交叉的!
只能说,“最早发生”的时机,是按照这个顺序发生,但是中间加载过程是有很多的,具体后面会结合我画的图以及原理解释进行呈现。

加载:不仅仅是读取字节流#

image.png
对于加载,很容易只理解成只是“从文件里加载二进制字节到内存”。
这个过程显然是必须最先执行的,否则连类的基本信息都获取不到。
image.png
可以看到这个过程很灵活,只要你从你能想到的地方拿到字节流即可,任意形式都行。

然而,对于“加载”,除了获取字节流,实际上还包含了“把字节流转成方法区里的数据结构,进行存储defineClass”、“生成一个class对象,存储在堆中”这两步。

这2步是穿插在连接过程中的。
比如字节流转数据结构的过程,必须在确认字节流的正确性之后完成。
而生成class对象同理,符合一个class对象的条件时,才能将其在堆中生成。
image.png
image.png

加载过程是由类加载器classloader完成的,在这里对classLoader也顺便做一个详细的分析。

类加载器#

双亲委派#

类加载时的双亲委派模型,反正就记得优先去父类加载器中看类是否能加载。

这个过程和多态方法调用是相反的,多态方法是子类覆写了的话则优先子类调用,类加载则是父加载器能加载则加载。

注意:Bootsrap不是ClassLoader的子类,他是C++编写的。
而ExtClassLoader和AppClassLoader都是继承自ClassLoader的

a4c77999a2f8a0d5bc6401d05efeca9cdcecef43

双亲委派的详细执行过程和中间方法#

loaderClass(className) 双亲加载实现(这里会体现先去父亲找,再自己)注意,jdk1.2之后不提倡覆盖loadClass方法,这个方法可以理解为一个模板方法

但如果确实有需要破坏双亲委派的需求,则可以重写loaderClass方法,解除双亲委派机制

findClass() 如何根据名字,寻找并生成1个class(内部需要借助defineClass)
defineClass() 通过这个方法将字节码生成1个class类,基本不用改动。

例如需要根据类目,从某个远端网络加载获取这个类, 而且获取过来的时候还是加密的,需要在findClass里对byte数组做解密,解密完成,再调用defineClass生成class类。

https://blog.csdn.net/zzti_erlie/article/details/82757435

双亲委派的好处#

书中提到的原因就一个: java类随着类加载器,具备了带有优先级的层次关系。
保证了例如object类在每个环境里都是同一种,不会出现混乱。

父加载器中要加载某个类A时,A需要使用子加载器来加载,但是父加载器没有子加载器的代码,怎么办?#

可以使用线程上下文TCCL机制, 例如java的JNDI服务,JNDI是在启动类加载器里入加载的(JDK1.3的rt.jar), 但是JNDI会加载很多扩展性很强的新资源类。

因此可以在JNDI加载类的过程中,从TCCL这个context对象中,拿到set进去的用户加载器,然后进行加载即可。(JDBC、JBI等SPI机制都是如此)

OSGI网状类加载器#

OSGI中, 每一个程序模块(bundle)都有一个自己的类加载器,当需要更换一个bundle时,就把bundle连同类加载器一起换掉,实现代码的热替换

它是一个网状的类加载结构,只有java.*以及委派名单的,才会用双亲委派机制,否则都是各种网状的加载。加载过程如下所示:

OSGI加载

为什么OSGI可以实现热部署,但是双亲委派不可以?#

首先,理解双亲委派模型下,不能热部署的原因:
如果有新升级的同名类要加入,它只能在新给出的一个加载器去加载, 但是双亲模型限定了必须是先交给父加载器加载,这导致了升级困难,无法让新的加载器去做抢先加载。

OSGi类加载模型则是网络图状的,可以在bundle间互相委托
例如bundleA、B都依赖于bundleC,当他们访问bundleC中的类时,就会委托给bundleC的类加载器,由它来查找类;如果它发现还要依赖bundleE中的类,就会再委托给bundleE的类加载器。

另一篇文章的解释:

  • 由于类加载机制的原因,导致一个类一旦加载进去就再也无法释放,因此,OSGi引入了基于插件的类加载机制
  • 举例说明:plugin1里有examples.Test1类,而pulgin2里也有examples.Test1类,在载入这两个插件时,两个类是可以同时载入进入到类缓存中,这归功于OSGi实现的插件类加载器(ClassLoader)
  • 我们把“examples.Test”服务的 Service Ranking 属性更改成了 100,那么其他bundle使用这个bundle对应的example类时,就会用优先级最高的类。
  • 在不需要停止服务和其他插件都不用更新的情况下,我们只需要再安装一个更新版本的插件,其所注册的服务就可以自动更新并应用到所有调用该插件的插件中,达到了热部署的目的。
    OSGi的热部署特性及实现

数组类是如何加载的?#

数组类是由AppClassLoader加载的。
数组类打印className时,前面会有个[Lxx类
二维数组就是[[Lxxx类
数组类的父类型是Object

注意此时加载的是数组类,而数组类里面的对象是不会做自动加载的
因此xx类的静态代码并不会被直接调用

连接#

连接过程可以说是最难记住的一个过程, 里面包含了各种校验啊之类的,让人摸不清头脑。这里会通过更细致的解释和图解,让你明白连接过程究竟做了什么。
首先连接过程分为 验证、准备和解析,“解析”并不是连接的最后一步,而是在验证过程中实时发生的!。 下文会为你详细解释为什么。

验证#

文件格式校验(class文件对不对)#

image.png
注意这里的校验,都是一些最简单的校验,相当于无需做太多的语法分析操作等操作, 都是基于class文件格式定义进行的基础校验。

然而如果对加载的文件有充分的自信,来源可靠,那么确实可以省去这个步骤,提升连接效率,因此会有一个-Xverify:none的选项供使用。

元数据验证(我的父亲对不对)#

这里验证了class文件里面继承特性相关的重要信息,例如继承关系是否合理、是否实现了抽象类或接口的方法
image.png

注意,这个元数据验证的过程,会触发父类或者接口的解析(加载)操作!
书上提到了4个解析情况以及流程:

  • 类解析
  • 字段解析
  • 类方法解析
  • 接口方法解析
    却没有解释这4个解析过程是在哪里发生的。后面我会逐一提到,来真正理解这4个解析过程。

元数据验证中的类解析#

还记得class文件中,父类是指向一个constant_class_info吗?这个东西当时看就是一个utf字符串,没什么意义。你没法知道父类究竟有什么方法,是不是抽象类。
因此必须拿到父类的类信息,要么是已经在方法区中,要么需要重新加载。
而类解析的过程如下:
image.png
可以看到这个过程中也会发生加载,甚至好多次加载。


字节码验证(我的指令对不对)#

image.png
这个验证不要和前面的“文件格式验证”搞混了。
前面的“元数据验证”都只是针对类、方法、字段等和父类进行确认、校验。
但是还没有涉及到每个方法里的code属性。

code属性虽然在编译出来时是正确的,但是无法保证传输过程中被人篡改。
如果发生操作操作数栈时,栈里没东西,或者试图在局部变量表边界外写入局部变量,就可能导致不可估量的后果。

因此此刻会进行最基本的指令分析,确认对操作数栈、局部变量表的操作是安全、正确的。

但是,逐个指令分析,会不会太慢了?如果代码很长的话。

还记得class文件的code属性中,还包含了一个stackMapTable属性么,估计很多人都跳过了这个属性。
image.png
这个属性就是用在字节码验证这个过程,可以立即让编译器编译出class时,提前把各位置的情况写入stackMap中,jvm加载时只对这个stackMap做校验确认是对的即可。
但代价就是可能不安全了,因为这个stackMap是可以被篡改的。

符号引用验证(我的指令调用的目标对不对)#

注意前面的“字节码验证”是简单的确认,但不会持有过多的其他类的信息。
但是方法肯定会涉及对其他类的调用。

image.png

此时就会涉及到符号引用验证,确认自己是否拥有对方方法的访问权限。
那么你就需要找到目标类的类信息存放地址,确认方法权限,或者字段权限。
于是会在这里触发字段解析、类方法解析或者接口解析!
image.png
书上只提到了这3个解析过程的流程,却没有详细解释其中的一些缘由,我会做更详细的补充。

符号引用验证中的字段解析#

class中的constant_filed_info终于露出了它的真面目,原来是用在这个地方,即和字段相关的指令会用到它,并通过字段符号引用, 解析到这个字段真正的定义位置。
image.png
像经常遇到的NoSuchFieldError报错,就是在这个过程中爆出来的。
而且接口字段的优先级是大于父类的字段的。

符号引用验证中的类方法解析#

当调用方法前,需要先确认对象方法是否有权限访问。那么就必须这个类的信息进行确认。
注意:这个过程并不是动态分派的那个过程,此刻并没有触发任何的方法调用!仅仅是确认代码中静态类型的访问权限是否正确之类的!
image.png

  • 对类方法做解析的时候,会判断此时是类还是接口。如果是接口,竟然会报“IncompatibleClassChangeError”。
  • 还有如果是抽象类,也会报“AbstractMethodError”,因为正常情况下,你的jvm指令调用的方法,必须是实例化的对象所对应的方法,不可能直接调用抽象类方法的。

符号引用验证中的接口方法解析#

看起来像是将类方法解析中的接口和方法互换了位置。
image.png

疑问1:为什么接口方法还要解析?接口里不是没有代码吗?#

因为接口类里每个interface方法,本身也是一个方法,只不过没有详细的code属性。但方法的访问修饰符之类的都存在,因此验证阶段还是需要进行校验。

疑问2:为什么要区分类的方法和接口方法?不能用同一种思路去解析么?#

我理解的几个原因:

  1. 向上搜索时的逻辑不同,对于类方法,直接找父类即可, 而接口则需要遍历所有父接口。而且类方法还要考虑抽象类的问题,接口不需要。
  2. 类方法和接口方法本身就是两个不同的符号引用, 一个是constant_method_ref,另一个是constant_interface_ref,用2套逻辑没什么毛病
  3. 如果硬要问为什么要区分这2个符号引用,明明内容都是类索引+描述符索引?
    这是因为后面在实际调用方法时,二者有显著区别,具体见下文的“方法表的准备”。

准备#

image.png

类静态成员默认值的准备#

对于准备阶段,大家一般只记得需要对一些非final的类静态成员做默认初始值操作。

方法表的准备#

除了这个默认值赋值,还有一个动作,是准备方法表。
方法表就是为了多态而生,简化动态分派时频繁的迭代循环带来的不必要消耗:
image.png
通过前面的验证过程,我们已经获知了父类信息。
因此可以准备一个方法表,把父类方法堆到最前面,自己的方法堆到后面,后面直接根据索引获取方法调用地址即可!

重要问题:interface的接口方法,会有方法表吗?#

intefacer接口是不具有方法表的!
因此这可能也是jvm特地区分了class_inteface_info和class_method_info这2个常量,以及特地用invoke_inteface和invoke_virtual指令来区分2类方法的调用。因为他们的调用逻辑可能大相径庭。

为什么接口不能有方法表?#

这是由于Java可以实现多个接口,不同的类可能会实现了多个或者不同的接口,在虚表里该接口所实现方法的索引会不一致。

假设有A、B、C三个接口类

  • 类X实现了A、B两个接口,假设A和B接口放在虚表里,那么调用A接口方法我们假设它是在t位置。
  • 类T实现了B、C、A接口,按照实现顺序,先放B的方法,再放A的方法,最后放C的方法。这样调用接口A时,就不一定是t位置了,我们无法直接确定A里面方法的位置,因为一个类可以实现多个接口,而且顺序可以随意更改!

这样每次解析的虚表索引都可能会不同,因此不能进行缓存,需要每次都进行重新的解析。
因此,接口的方法调用会比普通的子类继承的虚函数调用要慢。

java的虚表和C++的虚表有什么区别?#

C++:
当编译器遇到调用虚方法的代码时,是通过vtable指针以及对应方法在虚表里的offset,然后获取对应的函数指针实现的,由于offset在编译过程就已经固定了,这样在执行过程中几乎没有产生任何额外的计算就实现了多态调用,效率相当高。
缺点就是当你修改了一个dll链接文件,另一个dll链接文件可能还是用的老的偏移,这导致你即使重启程序了,仍然还是错误的调用。 你必须将两个链接文件都重新编译才可以。

但对于java而言,只需要替换一个jar包即可, 类之间的方法调用关系,方法偏移,都是可以类加载过程中去生成的。

换言之,最大的区别就是修改部分方法带来的影响,java是最小的

java虚表的生成过程#

  1. 在加载该类的时候,常量池的所有虚函数的签名(包括调用的以及自身定义的)都会添加到全局的符号表(事实上是一个HashTable)。
  2. 首先对字符值进行Hash值计算,然后在全局HashTable进行查找,如果发现已经存在对应的Hash值,则返回对应的符号指针Symbol *,否则创建新的Symbol并添加到HashTable中,然后返回新创建的Symbol *。这样常量池就把字符串的引用转换成符号的引用。另外这个过程可以确保所有字符串在jvm只存有一个引用。、
  3. 当在某个类对象调用虚方法的时候,通过调用函数的符号和自身定义的符号进行比较(由于这里都是引用全局符号表的唯一符号,因此可以通过内存地址进行快速比较),就会解析出调用虚函数的信息,通过信息就可以获取虚表的索引,然后调用对应的虚函数字节码
  4. 为了提高调用时的性能,Java采用的是Lazy解析,第一次解析出虚表的索引后,则会保留到cache里面,这样下次调用就可以从缓存直接获取索引

解析#

解析的误区:并不是一个单纯的阶段#

解析其实分为“静态解析”和“动态解析”。
因此将解析说成是“连接”中的一部分是不严谨的, 只有静态解析,才是“连接”的一部分。

静态解析在初始化前发生,但动态解析则可能在初始化或者初始化之后才去使用。

静态解析#

静态解析用于解析私有方法、父类构造器、final方法等不存在多态可能的方法。
image.png

解析和分派的区别#

静态分派、动态分派, 指的是2个方法的不同阶段,他们不存在冲突的关系,即方法会先触发静态分派,再触发动态分派。

静态分派可以理解为编译器在编写class文件时,通过方法名+描述符+优先级,确定了这个位置调用的是哪个方法。

但是由于多态的特性,具体执行者可能不同,因此后面还会触发动态分派。

而静态解析和动态解析是2个不同的解析,前者是默认定死了方法引用位置,后者则必须依赖动态分派,对一个方法而言不可能同时存在动态和静态解析的情况。

初始化#

cinit方法细节解析#

image.png
关于初始化时机的解释,在开头就已经阐述过了,这里不再重复解释。

疑问1:cinit方法中的代码是如何生成的?#

cinit方法 是编译器收集所有类静态变量的赋值动作和静态语句块static{}中的语句合并产生,按照顺序收集。
因此类加载赋值的顺序和类定义顺序有关,原理就取决于cinit生成的原理。

疑问2:cinit类初始化是线程安全的吗?#

是线程安全的,虚拟机会保证一个类的加载和cinit方法会被正确的加锁、同步。
因此多线程场景下,同时使用一个之前没初始化过的类,且类初始化过程耗时非常久的话, 且可能会造成线程阻塞。
而这也是可以利用类初始化+内部类的方式,来做单例模式的实现的原理:

初始化中的动态解析#

而初始化过程中,可能会涉及其他对象实例方法的调用,因此是可能发生动态解析过程的!
类方法和接口方法的解析过程如下
类方法的解析可以借助虚方法表简化解析过程。
image.png

扩展:invoke_dynamic是什么#

动态语言和静态语言的区别#

动态类型语言: 类型检查的主体是运行期而不是编译器。 例如PHP、Lua\python
而静态类型语言就是编译器将类型都检查完,比如C++、java

静态语言的好处:在编译器就能确定类型,可以进行严谨的类型检查, 代价就是代码会很臃肿。
动态语言编写时更为随意,可以快速开发和运行。

java的MethodHandle用法#

jdk1.7之后提供的 MethodHandle, 类似于C/C++里的函数指针, 或者C#里的delegate。
C里面可以
sort(list, size, int (* compare)(int, int))
即传入一个函数指针,这个函数是哪个类调用的?不知道

java提供了methodHandle用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public static class ClassA {
public void println(String s) {
System.out.println("this is a print:" + s);
}
}

public static MethodHandle getPrintlnMethodHandle(Object object) throws NoSuchMethodException, IllegalAccessException {
// 返回值,入参
MethodType methodType = MethodType.methodType(void.class, String.class);
return MethodHandles.lookup()
// 找到对象所对应的类,确认是否存在方法
.findVirtual(object.getClass(), "println", methodType)
// 这个对对象绑定上去调用. java的反射不是也能做到吗》?
.bindTo(object);
}

public static void main(String[] args) throws Throwable {
Solution solution = new Solution();
ClassA classA = new ClassA();
// invokeExact:执行并传入参数,classA和System.out不是同一个父类或者接口,但是可以执行相同的方法
getPrintlnMethodHandle(classA).invokeExact("afdsafds");
getPrintlnMethodHandle(System.out).invokeExact("afdsafds");
// 以后排序就可以这样做了: sort(list, MethodHandle mt)
}
}

其中methodHandle背后就是由invoke_dynamic指令触发的。

methodHandle典型应用场景:如何调用爷爷类的虚方法#

如何在儿子类中,分派调用祖父类的虚方法? 且儿子类和父类都已经实现了这个虚方法。父类不能提供新的方法

通过loopup().findSpecial(祖父类, “方法名”, MethodHandle.methodType(返回值,入参), 当前类)
拿到一个方法句柄mh
然后方法句柄mh.invoke(this),即可调用祖父类的方法。

java的methodHandle机制和reflection的反射有什么区别吗?#

  1. 调用指令层面:
  • reflection是模拟java代码调用,不关心底层指令
  • 而methodHandle是模拟了字节码的执行,上面的Loopup().findVirtual,等同于invokevirtual指令(同理还有invokestatic\invokeinteface\invokespecial)
  1. method对象大小问题
  • reflection返回的method对象,包含的信息更多,例如签名、描述、属性等,返回的method比较重量级
  • 而methodHandle仅包含执行方法相关的信息,是轻量级、
  1. 可以基于methodHandle手动做虚拟机的相关调用优化(例如内联),而反射无法实现。
  2. 从最终设计目的而言,反射只针对java, 而methodHandle的核心目的在于可以将其他无类型的语言运行在java迅即之上!

invoke_dynamic指令原理。是否涉及动态分派、类加载和解析?#

我们首先看下invoke_dynamic指令调用的dynamic_info常量长什么样的:
image.png
可以看到它只包含了一个方法索引和描述,但似乎没包含方法属于哪个类。

它的作用是用java实现一些类似于脚本语言的逻辑,脚本语言不关心静态类型,不做编译检查,只关心运行期的内容。所以invoke_dynamic以及constant_dynamic_info应运而生。但书本和工作中对这块的接触都不是太深,因此我的理解也只能局限于此了。

书上还有句话可以记一下:除了invokeddynamic动态调用指令, 其他的invokevirual之类的,都会缓存解析结果。


最后的完整大图#

在线地址
https://www.processon.com/view/link/5e7eed6ce4b0ffc4ad43fda8

完整大图

重读了一遍《深入理解java虚拟机》, 发现第一遍读垃圾回收器相关的进化历程时,没有细细去研究各自的区别,觉得太多了记不住。
实际上理解了这个进化过程,这对于我们理解回收器是有很大帮助的。

看来经典书籍要多读多总结,是有道理的。
于是在阅读这个章节时,画了一张大的演化图,方便理解变化和区别。

[toc]


Serial Old和Serial(单线程收集器)#

image.png

  • Serial Old指的是老年代收集器,使用标记-整理清理垃圾
  • Serial指的是年轻代收集器,使用复制算法清理垃圾。

Serial就是单线程的意思,不仅代表它使用单线程做回收,更意味着他会进行stop world暂停工作线程

Serial类型的收集器被淘汰了吗?它还有优势吗?#

没有,它是client模式下默认的收集器。
优势在于,它具有最高的单线程收集效率
而client模式一般不会用于处理大量请求,因此非常适合serial。

除了client,书上还提了另外2个功能:

  • Serial Old收集器会作为CMS的后备预案
  • 与Parallel Scavenge搭配使用

Q: 什么时候可以触发stopWorld?(安全点,GC时间点)#

  • 方法返回之前
  • 调用某个方法之后
  • 抛出异常的位置
  • 循环的末尾

4个安全点的设计思路是什么?#

当垃圾收集需要中断用户线程的时候, 不直接对线程操作,而是设置一个标志位.

各个用户线程执行过程中会轮询这个标志位的状态.

一旦用户线程轮询到标志位被设置过, 就在最近的安全点主动挂起,所有用户线程都挂起, 垃圾收集真正进行

Q: 和安全点对应的安全区又是什么?#

A:
程序可能因为blocked或者sleep,无法到达安全点。
这是可以设置一个安全区域,这个安全区域对应的代码段,引用关系不会变。JVM检测到程序在安全区域时,可以进行GC。
程序运行出安全区域时,检测GC没有结束的话,自我中断。

ParOld和ParNew收集器(多线程收集器)#

image.png
Par收集器就是Serial收集器的多线程版本,其他策略则都与serial一致。

注意, 虽然是多线程收集器,但是用户的工作线程仍然是暂停状态(为了防止收集过程中发生变化导致回收错误

ParNew收集器可以与CMS收集器配合使用。

Parallel Scavenge收集器(对回收时间的优化开端)#

image.png

Parallel Scavenge 收集器是一个新生代收集器,他不包含老年代。
前两代的收集器,默认必须收集完成,对工作线程影响巨大。
这是首次开始关注回收时间对工作线程影响的一代收集器,成为了垃圾收集器升级优化的一个重要开端。

如何理解吞吐量#

虚拟机运行了100分钟, 垃圾收集花掉了一分钟,那么吞吐量就是100%。

Parallel Savenge收集器是如何控制吞吐量的#

通过控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数,以及直接设置吞吐量大小的-XX:GCTimeRatio参数。
通过修改这2个参数,jvm可以计算一个合适的新生代空间,空间越小,回收时间越快,他的停顿时间便能够满足吞吐量要求。

  • 换句话说,本质上就是通过你设定的吞吐量或者暂停时间,自适应地得到一个新生代空间大小而已。

代价是什么?#

新生代越小,那么意味着老年代的空间就越大。

虽然能做到基本不停顿或者停顿间隔很小,但这样就会导致新生代频繁发生minorGc,并不断将垃圾扔给老年代收集器,容易在下一个时间段触发更多的fullGc。
因此这个策略仅仅是饮鸠止渴,无法真正解决问题。

  • 注意,这里ParaleelSavenge的吞吐量,指的就是新生代的吞吐量,不代表fullGc占用的时间。

CMS收集器(Concurrent Mark Sweep, 并发收集概念的重大提出)#

image.png
而从这一代开始,jvm终于想到了可以如何尽可能少地暂停工作线程的方式,提出了并发收集的概念。

首先明确并行收集和并发收集的区别

  • 并行收集:指用多线程来收集,但是工作线程仍然暂停
  • 并发收集:收集线程和工作线程允许不冲突地交替并发执行

CMS是老年代收集器,必须和parNew等结合使用
使用的是回收-清除算法,有较多碎片。


CMS阶段1:初始标记#

初始标记就是对GCRoot对象进行标记,以GCroot作为起点。
GCRoot的那4种经典对象,瞄一眼就好

虚拟机栈(栈帧中的本地变量表)中的引用的对象
方法区中的类静态属性引用的对象
方法区中的常量引用的对象
本地方法栈中JNI(Native方法)的引用对象

GCRoot的选取原则是什么?#

这个问题很有意思,为什么要这样选择?如果能理解这个问题,也就不需要去死记硬背上面的内容了。

首先,可能有很多个方法栈,每个栈都有一个栈顶的栈帧,说明这是正在执行的方法, 在此刻是一定不需要被回收的!
因此选取了2种栈的栈顶作为GCRoot选取位置。

而方法区中的对象一般不会被释放,长期持有,因此方法区中的静态引用对象、常量引用对象也是稳定能被使用的。

ParNew年轻代收集时,需要遍历CMS老年代的所有GCROOT吗?#

CMS是老年代的收集器, 经常要和年轻代收集器例如ParDoNew配合。(因此上面4个阶段都是处理老年代回收的,年轻代内存占用小,不需要那么麻烦)
那么当ParNew年轻代回收时,是否也要把老年代的所有GCROOT都算上?后面全部遍历的话,时间是不是太久了?

因此才有了卡表的出现!
卡表作为一个比特位的集合,每一个比特位可以用来表示年老代的某一区域中的所有对象是否持有新生代对象的引用。
这样新生代在GC时,可以先扫描卡表,只有卡表的标记位为1时,才需要扫描给定区域的年老代对象。而卡表位为0的所在区域的年老代对象,一定不包含有对新生代的引用,从而提高了年轻代的回收效率!

CMS阶段2:并发标记#

这时候不会做stopWorld。标记线程和工作线程同时进行。

并发标记用了怎样的算法去标记的?#

当通过gcRoot做并发标记的时候,是一种bfs搜索。
有一种三色标记法可以作为参考:

  • 白色:还没有搜索过的对象(白色对象会被当成垃圾对象)
  • 灰色:正在搜索的对象
  • 黑色:搜索完成的对象(不会当成垃圾对象,不会被GC)
  1. 默认起始是白色节点。
  2. 是每次标记当前搜索节点的引用节点(类似于相邻点)为灰色,入队列。
  3. 当相邻点全部入队列完成,则把当前搜索节点置黑色。然后根据队列取队头继续处理灰色节点

是不是和数据结构的bfs非常类似?

并发标记时如何记录引用变更?#

对于CMS在并发标记时的引用变更,书上没有细讲,只是一笔带过,个人认为错失了许多精华。
有些类似的概念确实有在G1收集器里简单阐述,但是很难让人马上和CMS中对应起来。个人认为应该在CMS的章节就提前给出。
下面以我自己的理解,给出对CMS并发标记过程的理解。

什么是跨代引用?#

首先基于上面提到的三色标记,给出跨代引用问题的例子和解释。
假设此时正处于并发标记中,且正好在bfs处理A这个节点。
image.png
这时取消了A对B的引用,以及B对C的引用,同时新增了A对C的引用,变成如图所示:
image.png
那么当继续搜索入队的B时,将无法再走到C,C永远被标记为白色,就会出现严重的后果:误杀了C,从而导致A对C调用时报错!
image.png

如何避免跨代引用,保障并发标记安全(写屏障#

CMS引入了一个叫“写屏障”的东西,写屏障工作示意如下:
image.png
而标记栈就会在后面的“重新标记”阶段用上。

CMS阶段3:重新标记#

前面的写屏障为我们把为标记却被新增引用的对象放入了栈中。
此时会进入StopWorld,我们可以从栈中取出标记对象进行“重新标记”了。
image.png

CMS阶段4:并发清除#

最后清除的时候,选用了“标记-清除”算法,来进行回收和处理。
同时采用并发机制,避免影响了工作线程。
“标记-清除”算法的示意图如下:
image.png

为什么是要用“标记-清除”这么缺点大的方法?#

因为老年代算法,要么是标记-整理,要么是标记-清除
而标记-整理算法是无法和工作线程并发执行的
所以才选择标记-清除,这也导致了碎片带来的隐患

CMS如何解决标记-清除后碎片过多,无法放入新对象的情况?#

当因为碎片过多,无法放入新对象时,会触发fullGC,此时会做1次内存碎片的合并(整理)操作

还提供了一个参数,设置多少次非合并的fullGC时,可做一次碎片的集中合并和整理。

并发回收过程中,如果工作线程突然生成大量新垃圾,导致内存不足怎么办?#

因为并发回收时工作线程还在运行,可能产生大量的对象,导致老年代被填满。
这时候CMS会触发一个“Concurrent Mod Failure”机制,并紧急替换为SerialOld收集进行stopWorld回收。
因此,CMS可能存在临时退化为SerialOld的可能

混合使用收集器(CMS+parNew)#

G1收集器出现之前, 都是老年代和年轻代一起使用, 例如CMS+parNew的组合

Q: fullGC和monorGC分别发生在老年代区还是新生代区?#

A:

fullGC只会发生在老年代区。 会用上CMS那堆很复杂的操作

相反,minorGC一般发送在新生代区,直接用parNew那种复制算法,很快。

Q: 新生对象什么时候可以尝试进入老年代?#

  1. 发生minorGC后, E区和survivor区都放不下了

  2. 在survivor区待的时间过长,gc分代年龄超过阈值

  3. 该GC分代年龄对象总和大于等于s区的一半

  4. 新生对象的大小大于某个阈值

Q: Eden年轻代区和survivor区的比例为什么是8比1比1#

A:

正常预留10%内存空间, 大于90%进行minorGC
但为了复制算法需要, 得有这10%的复制

且有-XX:SurvivorRatio=8 这个参数值可以修改

Q: 讲一下老年代的担保策略是做什么的?#

用于确认此时到底是做minorGC还是做fullGc。

  1. 在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间
  2. 如果这个条件成立,那么Minor GC可以确保是安全的。
  3. 如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。
  4. 如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
  5. 如果大于平均大小,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的,有可能把老年代搞满(这时候还没有fullGC!);
  6. 如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC即全量GC,而不是minor小gc。

换言之, 要么选择minorGc,要么选择fullGc, 感觉有风险时,就fullGc。

新生代、老年代的交互流程:#

1660746972644

G1收集器(回收概念发生诸多变革,目前最先进收集器)#

image.png

G1收集器,书上感觉并没有讲得特别深,很多概念、区别都没讲好,个人认为是种遗憾,因此我在透彻学习G1之前,我也只能简单写写了。

G1相比CMS的重大升级点:#

  1. 回收范围不同。 CMS是老年代收集器,必须和parNew等结合使用。 G1则可以同时管理老年代和年轻代。
  2. 停顿目标不同。 CMS会让停顿时间尽可能小, G1则建立了可预测的时间模型。
  3. 清理方式不同。 CMS是标记-清除, G1是标记-整理,碎片大大减少。
  4. G1支持筛选回收 G1可以根据每个region的价值进行回收,CMS则不行。
  5. 并发标记后的最终标记处理方式不同
    这个标记方式的区别讲述起来有点抽象,简而言之就是:
  • CMS是希望记录所有新增的引用,并重新做好多次BFS,保证没有疏漏,代价非常大。
  • G1则是只更新o = null这种删除引用的情况。对于新增的引用,直接认为那个对象不需要杀。
    换句话说,CMS更严谨,做细致的重新检查。 而G1为了性能,会漏掉一些本该被回收的对象,但是无关大雅,大不了就下次再回收

G1里的region之间都要通过BFS遍历吗?#

这个问题,和之前CMS中回收年轻代时, 是否要走一遍全量的老年代是一个道理。

G1里用的是一个RememberSet来避免全region扫描的。
每个G1的region都有一个记忆集(Rset)
记忆集会记录下当前这个region中的对象被哪些对象所引用。
例如,region2中的两个对象分别被region1中的对象和region3中的对象所引用,那么,region2的记忆集记录的就是region1和region3中的引用region2的对象的引用。

这样一来在回收region2的时候,就不用扫描全部的region了,只需要访问记忆集,就知道当前region2里面的对象被哪些对象所引用,判断其是不是存活对象。

简单来说,就是标记我这个region被哪些region引用,简化扫描,避免不必要的检索。


但是书上提到了一句话(P85):

“通过cardTable卡表把相关引用信息记录到被引用对象所属的region的rememberedSet之中”

这里我就点没看懂,卡表不是老年代对年轻代的引用么,为什么G1里也有?不是用了记忆集吗?不解,等以后有解答了,再来修改这里的内容。


Q: G1里的minorGc、 majorGc、mixedGc、fullGc的区别?

A:

  • minorGc也就是youngGc。工作流程很简单:线程跑,然后就进行青年代Region的回收,把需要回收的YoungRegion,放入YoungCSet中,在YGC阶段就进行对年轻代CSet中的Region进行回收。因为大部分都是垃圾,且用了复制回收算法,基本只需要较短时间的STW就能完全回收了。 这里不会触发
  • MixedGc等于年轻代+部分老年代gc。当老年代垃圾达到一个阀值的时候,会触发,这里就会用到G1里的并发标记、最终标记、筛选回收等操作。但不会针对所有老年代,只筛选需要处理的老年代。
  • FullGc,等于整个堆空间的清理。 当新对象进入老年代时空间不足, G1便会触发担保机制,执行一次STW(stopWorld)式的、单线程的Full GC。Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。 注意这时候不会用到并发之类的,直接暂停了。

完整大图#

关于垃圾收集,书上倾向于先将一些基本概念或者基本回收思路,再讲发展流程,同时对G1缺少更细致的解释,这就容易混杂起来,导致垃圾收集器的进化那一章节看得很迷。

后面找到了一本书,叫做《The Garbage Collection Handbook》,已经收藏,有时间的话可以看看,据说对G1做了非常细致的讲解
image.png

最后送上完整大图:
垃圾收集器大图

其他问题#

Q: 方法区里的class对象(即类对象)什么时候会被回收?#

A: 所有实例都被回收、对应classLoader也被回收、class对象不会再被引用或者反射(这个咋确定?当初书里看到的,没懂)

Q: 什么时候会调用对象的finalized方法#

A: JVM启动垃圾回收,且该对象要被回收时。

finalized应该更多是规范吧,很多规范里都要求我们不要自己实现finalized了,毕竟不确定性太大。

(1652355722943757845.png)[http://localhost:4000///֪ʶ��/java/images/jvm�ڴ�����ԭ��/1652355722943757845.png]
(1652799978355467461-1653808433418.png)[http://localhost:4000///֪ʶ��/java/images/jvm�ڴ�����ԭ��/1652799978355467461-1653808433418.png]
(1652799978355467461.png)[http://localhost:4000///֪ʶ��/java/images/jvm�ڴ�����ԭ��/1652799978355467461.png]
(1652800419071211329.png)[http://localhost:4000///֪ʶ��/java/images/jvm�ڴ�����ԭ��/1652800419071211329.png]
(1652800601102172178.png)[http://localhost:4000///֪ʶ��/java/images/jvm�ڴ�����ԭ��/1652800601102172178.png]
(1652800699980785954.png)[http://localhost:4000///֪ʶ��/java/images/jvm�ڴ�����ԭ��/1652800699980785954.png]
(1652800735895914907.png)[http://localhost:4000///֪ʶ��/java/images/jvm�ڴ�����ԭ��/1652800735895914907.png]
(1652800897811439190.png)[http://localhost:4000///֪ʶ��/java/images/jvm�ڴ�����ԭ��/1652800897811439190.png]
(1652800987016832820.png)[http://localhost:4000///֪ʶ��/java/images/jvm�ڴ�����ԭ��/1652800987016832820.png]
(1653813729000.png)[http://localhost:4000///֪ʶ��/java/images/jvm�ڴ�����ԭ��/1653813729000.png]
(1653813761145.png)[http://localhost:4000///֪ʶ��/java/images/jvm�ڴ�����ԭ��/1653813761145.png]
(1653814723600.png)[http://localhost:4000///֪ʶ��/java/images/jvm�ڴ�����ԭ��/1653814723600.png]

重读了一遍《深入理解java虚拟机》, 发现第一遍读“运行时数据区”相关内容的时候,只关注了最简单的概念部分,对于其中的细节部分没有深入探究,觉得那些东西太底层了,没啥用。
其实他们背后的原理,和我们平时运行进程时的各种报错息息相关。
另外如果能理解运行时数据区,也能够对“代码究竟是如何运行的”有更深的理解。

看来经典书籍要多读多总结,是有道理的。
于是在阅读这个章节时,针对每个结构,思考了非常多的问题,提出了很多QA,方便进行深度的思考和学习。

[toc]


jvm全局结构#

首先是一张经典的jvm运行时内存区域划分的图,我自己画了一张:

1653815416671


Q: 存在多个线程时,刚才提到的5个区域是怎么分布的?
A:
每个线程,都有自己独立的虚拟机栈、独立的程序计数器PC。

而方法区和堆是线程们共用的。


java堆#

java堆的内容比较多,这里不探究对象分配的原理,后面会补充新的文章。这里只讨论堆的一些其他细节问题。


Q: 堆是线程之间共用的,但这样会导致频繁发生冲突,是否要考虑并发问题?怎么办?#

A:
线程分配堆空间时,会先根据TLAB进行独立分配。

TLAB ——Thread Local Allocation Buffer, 中文名为线程本地分配缓冲区。
启用了 TLAB 之后(-XX:+UseTLAB, 默认是开启的),JVM 会针对每一个线程在 Java 堆中预留一个内存区域。
一旦某个区域确定划分给某个线程,之后该线程需要分配内存的时候,会优先在这片区域中申请。这个区域针对分配内存这个动作而言是该线程私有的,因此在分配的时候不用进行加锁等保护性的操作


Q: 但是如果恰巧多个线程在试图竞争同一个TLAB预留空间时(即都在试图扩容),发生冲突怎么办?#

A:
在预留这个动作发生的时候,需要进行加锁或者采用** CAS(compareAndSet) **等操作进行保护,避免多个线程预留同一个区域


Q: 分配的时候,在TLAB区域里,怎么知道放在哪个位置呢?#

A:
具体的分配内存有两种情况(和垃圾回收机制有关)

  1. 第一种情况是内存空间绝对规整。(对应使用回收-整理/复制算法的垃圾回收区)
  2. 第二种情况是内存空间是不连续的。(对应使用回收-清除算法的垃圾回收区)

对于内存绝对规整的情况相对简单一些,虚拟机只需要在被占用的内存和可用空间之间移动指针即可,这种方式被称为指针碰撞。

对于内存不规整的情况稍微复杂一点,这时候虚拟机需要维护一个列表,来记录哪些内存是可用的。分配内存的时候需要找到一个可用的内存空间,然后在列表上记录下已被分配,这种方式成为空闲列表。

程序计数器#

Q:PC计数器是整个jvm共有的吗?#

A:
不是的,是每个线程各自有一个, 而且是java自己定义的线程PC, 和CPU里的PC寄存器不同。


Q:PC计数器有啥用? 那如果没有PC寄存器呢? 我不是也能一条条执行,遇到return指令,返回对应地址即可,需要PC寄存器做啥?#

A: PC寄存器的作用在于多线程切换的时候,能找到每个线程执行的位置,所以它是线程私有的一个寄存器,知道当前运行到哪了。如果没有,一旦随机切换就不知道咋办了。你总需要一个地方存储这个线程当前执行情况,但又要保持独立性,所以不可能存到其他线程的空间里。


Q:为什么native方法的程序计数器为0(undefine)?如果发生线程切换,怎么办?#

A:
注意,jvm内存结构里的PC计数器是jvm自己定义的“字节码指令”执行寄存器。
对于native方法,并不在字节码的范围,不指向方法区里的任何指令位置。

因此native方法其实不是由jvm管理的,如果线程切换,他执行到哪边,取决于OS的底层机器码计数器实现。

以HotSpot VM的实现为例,它目前在大多数平台上都使用1:1模型,也就是每个Java线程都直接映射到一个OS线程上执行。此时,native方法就由原生平台直接执行,并不需要理会抽象的JVM层面上的“pc寄存器”概念——原生的CPU上真正的PC寄存器是怎样就是怎样。就像一个用C或C++写的多线程程序,它在线程切换的时候是怎样的,Java的native方法也就是怎样的。


Q: PC计数器里存的到底是啥?是指令地址吗?#

A:
错误! 存的不是地址,而是这个方法的字节码偏移。例如0、1、5、6这种。
image.png


Q: 那么怎么知道实际的字节码位置?#

A: 这个就要结合下文提到的栈帧中的动态链接,来联合计算实际字节码位置了。

虚拟机栈区域#


线程栈帧#

Q: 什么是栈帧?#

A: 每个线程有一个自己的栈帧,然后运行到每个方法时,每个方法中都会可以理解为是摄影里的一帧。
image.png


Q: 栈帧里包含什么?#

A:

  • 局部变量表
  • 操作数栈
  • 动态链接
  • 方法返回地址

其实与上面这4样配合的,还有个上文提到的“程序计数器”,才共同实现了jvm指令的执行。


Q: 栈帧的大小什么时候确定?#

A:
在编译程序代码的时候.
注意, 图例提到的栈大小,并不是指线程堆栈的最大深度,
而是指“操作数栈”的最大深度。(注意这个深度存在类文件字节码中对应方法的属性表中)
image.png

即jvm能够通过分析代码中可能存在多少个变量以及计算空间,来确定局部变量表和最大操作数栈的一个深度。

操作数栈#

Q: 什么是操作数栈#

A:
可以理解为jvm做计算时,需要一个临时的寄存器,把需要计算的数据或者传方法的参数放到栈中,然后做计算。
image.png


Q: 为什么一定要有操作数栈?#

如果要做a+b,我直接从变量表上取a的值和b的值,加起来不就好了?
A:
那我如果是 a + bc呢, 这个bc的值放哪里?
如果是a+b*(c+d)呢?
这时候如果你学习过数据结构里栈的应用 ,就会知道 模拟一个计算器,往往需要一个栈。
而操作数栈就是这个作用。
当你学习jvm指令时,就会看到有专门的指令就是取栈顶或者把值推送到栈顶的指令。
这样做加法的时候,也就不用关心变量的地址了,只要你把栈顶的值存好,我直接拿去加就行。


局部变量表#

Q: 什么是局部变量表?#

A:
每个线程所在栈帧都会有一个自己的局部变量表,里面存储方法中使用到的局部变量。
image.png


Q: 局部变量的槽又是什么?#

A:

  • returnAddress类型是为字节码指令jsr、jsr_w和ret服务的,它指向了一条字节码指令的地址。
  • 局部变量表的容量以变量槽(Slot)为最小单位,32位虚拟机中一个Slot可以存放一个32位以内的数据类型(boolean、byte、char、short、int、float、reference和returnAddress八种)
  • slot的长度可以随着处理器、操作系统的不同而变化, 不是绝对的32位。
    jvm概念中说的是”slot一定能存放下1个boolean\byte\int\引用地址\返回地址returnAddress“等不包括long在内的内容。
  • 如果要访问long,需要做2次局部变量slot的读取,读取n和n+1,不允许单独访问,如果有问题会在字节码校验中报错。

Q: 局部变量表里的returnAddress和栈帧里的返回地址returnAddress有啥区别?#

A:
局部变量表里的的returnAddress,是老版本jvm用于处理异常跳转的(jsr\jsr_w\ret指令,新版本基本都用code里的异常表来代替),而栈帧里的返回地址,是返回到上一层栈帧的代码调用位置,更新PC计数器用的。


Q: 局部变量表的slot可以被覆盖吗?这个设计有什么好处#

A:

  • 可以减少局部变量表的空间,通过分析每个局部变量的使用生命周期,在某变量不再被使用后,让其他变量可以覆盖这个槽的位置。
  • 另一方面,覆盖的机制,可以将一些局部变量上已经不使用的大对象解除引用,例如对一些大的变量做=null的操作,那么可以尽早进行垃圾回收(因为栈帧的局部变量表里的每个slot都是一个gcRoot)

Q: 设置null值,就一定会覆盖slot吗?#

A:
不一定,有时候JIT编译优化,可能会处理掉这个无用的=null的操作,且能正确处理slot中已经不被使用的变量。
按照书里的说法,正好有大对象,然后还停留在局部变量表里的概率是比较低的。不建议那么做了


Q:为什么java中局部变量没有默认初始?#

A:
我的理解,局部变量在局部变量表中,而局部变量表是运行时生成的, 而非在堆上生成,因此不会有堆对象创建时的那个默认值赋值操作。 即jvm定义上, 就是局部变量没有初始化前的’准备‘这个阶段的,也就不存在默认赋值的指令行为。

如果硬要说为什么,如果每个局部变量都复制,肯定会影响执行效率,因此不如不赋值。,所以必须通过赋值指令在运行时给他赋值。(没找到很好的解释,有更好理解的可以帮忙回答一下,其实就是)

另外如果每个局部变量都有,那可能指令数量就会变多,因为你需要放入很多赋值指令?

阅读JMM内存模型时的另一个解释:

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性.
线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM保证线程读操作读取到的值不会无中生有(Out Of Thin Air)的冒出来
对于全局变量(类对象成员),必须有默认初始化,为了满足多线程环境下的最小安全性。
但对于局部变量,不存在被多线程使用,因此一定后面可以拼接一个指令,所以不需要默认初始化的动作。


Q: 栈帧之间可能存在共享变量表的情况吗?#

即执行多次方法,一个栈上有多个栈帧,每个栈帧都有各自的局部变量表和操作数栈,然后上下层之间共享

A:
可能存在。即上下两个栈帧之间, 可能有操作数栈可以直接操作另一个栈帧局部变量的情况。这样可以避免额外的参数复制传递。
什么时候触发?不清楚


动态链接#

Q : 栈帧里的动态链接又是啥?#

A:
首先明确一点, 每一个栈帧,不一定是”动态”链接,但一定会有一个指向常量池中方法的引用。

为什么栈帧里需要存这个指向方法的引用?

首先,当你进入一个方法,准备生成一个栈帧,放到线程上时,你需要知道你这个代码执行的是什么代码,才能进行后面的操作。

如果是构造方法、final方法,则会编译器进行静态链接。
如果是虚方法,则会进行动态链接,运行期只是从类对象中,拿到了一个符号引用,

但是这个引用指向哪个方法?则通过下面的过程进行定位和寻找,把符号引用转成实际方法的直接引用。
image.png
因此要提供一个引用,指向常量池里的方法。指向后,就能知道程序位置。
然后字节码实际引用位置 + PC计数器偏移,就能知道当前线程执行到哪个方法的哪一步指令上了。

(关于动态链接这个名称的由来,是因为“动态分派”的存在,你这个方法位置是不确定的,和实际对象+方法名有关, 所以称为动态链接。)


返回地址#

Q: 既然有PC寄存器,栈帧里的返回地址的作用是什么?#

A:
方法A调用方法B的时候,PC寄存器会跟着移动到B方法去。当B执行完后,要能返回A继续执行,就需要A当时执行到的那条指令的地址。所以,在B的栈帧中保存A当时的指令地址(当时PC寄存器的值),当B执行完后,根据此返回地址跳回A。通过返回地址,从而知道当前线程的上一级应该从PC的第几行偏移开始。

另外除了正常通过ret指令退出,还可能是出现异常时,如果没有在异常表里被捕捉并处理,也会通过异常完成出口, 使用返回地址返回到上一层。


Q: 栈帧中的方法退出时,会触发哪些动作?#

A:

  1. 当前栈帧出栈
  2. 恢复上层方法的局部变量表和操作数栈
  3. 如果有返回值,把返回值压入操作数栈的栈顶(因为马上就要被调用了)
  4. 调整这个线程栈的PC计数器,改成returnAddress对应的那个指令位置地址,然后继续往下调用执行。

Q: 栈帧除了上面提到的几个,还有其他的信息吗?#

A:
有些支持调试的虚拟机,可能会补充很多调试相关的信息。

方法区#

Q: 方法区里存的是class字节码吗?#

A:
不是。经过类的加载、链接、初始化之后, class字节码对于进程来说就没用了。
存了以下内容:

  • 每个类的类型信息:类名、父类类名、修饰符、接口
  • 字段信息field(域信息):字段名、字段类型、字段修饰符
  • 方法信息,包括方法名、类型、参数、修饰符、字节码、一场表
    如下:
  • 类的静态变量
  • 常量池,存储常量
    注意,符号引用、类引用、实际类名等信息等都是放在常量池中的。

Q: 元空间与永久代到底是怎么回事?#

A:
方法区和“PermGen space”又有着本质的区别。
前者是 JVM 的规范,而后者则是 JVM 规范的一种实现
并且只有 HotSpot 才有 “PermGen space”,而对于其他类型的虚拟机,如 JRockit(Oracle)、J9(IBM) 并没有“PermGen space”。

元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小
-XX:MetaspaceSize和-XX:MaxMetaspaceSize


Q: 为什么要替换永久代#

A:
替换永久代的其他原因:

  1. 字符串存在永久代中,容易出现性能问题和内存溢出。
  2. 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
  3. 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

最后的感想#

好累,终于写完了,感觉能看到最后的人不会太多,但一通详细地分析和解决中间发现的问题,还是收获了不少。

关于jvm运行时数据区,最重要的不是去死记硬背,而是试图在脑中构建一个指令运行的逻辑流程。

且对于很多没有学习过计算机底层原理(例如CSAPP这本书) 的人来说, 是很难接触到计算机是如何执行机器码指令的。
而java虚拟机栈可以更好理解 指令是如何运行的(虽然这不是机器码,而是jvm字节码)。
但是通过运行时数据区的各种行为和概念, 我们可以快速对应到java中常见的各种操作。
这对于很多入门时直奔删减改查的同学来说, 是不可多得的学习底层的机会。

(1652269180508772459.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652269180508772459.png]
(1652269423338180081.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652269423338180081.png]
(1652271151313964089.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652271151313964089.png]
(1652271891308501334.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652271891308501334.png]
(1652272385081190034.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652272385081190034.png]
(1652275768314487190.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652275768314487190.png]
(1652317738015425066.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652317738015425066.png]
(1652355722943757845.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652355722943757845.png]
(1652355928364525434.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652355928364525434.png]
(1652395329500135926.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652395329500135926.png]
(1652395457163163025.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652395457163163025.png]
(1652395539642165989.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652395539642165989.png]
(1652396223751707461.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652396223751707461.png]
(1652396601906806136.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652396601906806136.png]
(1652397315515649848.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652397315515649848.png]
(1652397689648709059.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652397689648709059.png]
(1652397966852235844.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652397966852235844.png]
(1652398026144920939.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652398026144920939.png]
(1652398314647685943.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652398314647685943.png]
(1652398431353725133.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652398431353725133.png]
(1652398713702736921.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652398713702736921.png]
(1652399133483878712.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/1652399133483878712.png]
(5d1a2d97e4b0f7ca49d84a3a.png)[http://localhost:4000///֪ʶ��/java/images/java-class-file/5d1a2d97e4b0f7ca49d84a3a.png]

睡眠限制法#

  • 首要目标: 增加睡眠时间/卧床时间的占比,建立床=睡眠的潜意识。

  • 主要方式: 在无法显著增加睡眠时间的情况下,减少卧床时间。

  • 具体措施:

    1. 根据之前往期的入睡统计,安排严格的上床和入睡。例如晚上12点睡,早上6点30起。
    2. 困了就上床,上床后不要玩手机,避免增加无效卧床时间。
    3. 如果在床上不困,就立刻起床,避免增加无效的卧床时间。
    4. 减少午睡甚至不午睡,将困意集中到晚上的时间。

入睡氛围的构建#

  1. 睡前不要做需要耗费脑子导致精神集中无法放松的事情, 例如做算法题、高强度学习等。
  2. 提前1h洗澡、洗头,避免上床前才洗。
  3. 手机的新闻、游戏日常任务尽量白天能刷就刷,睡前告诉自己都已经刷过了,不需要再刷了。(可以将自己需要刷的东西列一个清单,睡前看一下自己是不是白天都刷过了)

如何处理睡眠时的焦虑#

  1. 睡少了 并不等于 第二天会完蛋。大部分人不会从事过于高精密的工作。
  2. 如果有睡眠外的焦虑, 尝试在睡前安排一定的”焦虑时间“,让自己充分将焦虑的想法念出来或者写出来,这样上床后如果还焦虑,可以告诉自己”刚才都已经焦虑过了,也写出来了,现在想也就没意义了“