• 书籍 - 深入理解java虚拟机
  • 资料 - 字节大佬面试宝典相关jvm内容
  • 博客 - jvm部分

1. 线程

Hotspot JVM 中的java线程与原生操作系统线程有直接的映射关系。

2. Jvm内存模型

image-20240624221754432

Jvm内存模型是Java虚拟机在运行时为Java进程对内存进行的逻辑划分,将其分为几个部分

  • 方法区
  • 堆内存
  • 虚拟机栈
  • 本地方法栈
  • 程序计数器

这些区域分别在不同数据结构对申请到的内存进行不同的使用。

2.1 本地内存

虚拟机没有直接管理但是却使用的物理内存,这些被利用却不在虚拟机内存数据区的内存,称为本地内存。

  • 本地内存并不是JVM运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分的内存也会被频繁地使用。而且也可能导致OutOfMemoryError异常出现。
  • 一般用于直接使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作,这样做可以避免了在 Java 堆和 Native 堆之间来回复制数据,从而提高性能。
  • 本地内存的分配不会收到 Java 堆的限制,但是会受到本机物理内存大小以及处理器寻址空间的限制。

2.2 虚拟机内存

Java虚拟机在执行时将管理的内存分配成不同的区域,这些区域被称为虚拟机内存。

受虚拟机内存大小的参数控制,当大小超过参数设置的大小时就会报OOM。

2.2.1 方法区(共享)

「各个线程共享的内存区域」,它「用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据」

  1. 在Java7及以前的版本,是存在永久代的。永久代与堆隔离,但物理地址隔离。

  2. 在Java7版本时,永久代已经发生了悄悄的变化。比如:

    • 符号引用(Symbols)转移到了Native Memory

    • 字符串常量池(interned strings)转移到了Java Heap

    • 类的静态变量(class statics)转移到了Java Heap

  3. 等到Java8时,彻底废弃了永久代,由元空间替换。元空间(Metaspace),不再与堆连续,而是直接存在于本地内存中,也就是机器的内存。理论上机器内存有多大,元空间的野心就有多大。

Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)。

  • 「常量池」
    • 字面量

      • 文本字符串
      • final常量
      • 基本数据类型的值
    • 符号引用

      • 类和结构的完全限定名
      • 字段名和描述符
      • 方法名和描述符

2.2.2 程序计数器(私有)

「程序计数器占用的内存空间很小,主要用来记录执行代码指令的」

程序计数器通俗的说就是:程序计数器记录当前线程所执行的字节码指令的行号,

「字节码解释器通过改变计数器的值来选取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等功能都需要依赖程序计数器来完成」


我们知道「Java多线程执行的时候是通过线程轮流切换并分配CPU的时间片的方式实现的,为了线程切换后能恢复到正确的位置继续执行,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,因此程序计数器的内存区域为【线程私有】的内存」

程序计数器的作用::

  • 「字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制」。如:顺序执行、选择、循环、异常处理。
  • 「在多线程的情况下,程序计数器用于记录当前线程执行的位置」,保证当线程被切换回来的时候能够知道自己上次运行到哪儿了。

「注意:程序计数器是唯一不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡」

2.2.3 Java虚拟机栈(私有)

我们在写代码的时候,会定义方法,方法里面可能会有一些方法内的局部变量。比如

1
2
3
public static void main(String[] args) {
int count = 1;
}

以上这个main方法在运行的时候,会有一个线程来执行它,这个线程就会有一个程序计数器记录这个方法被执行到那一步。在上述案例的main方法中有个count变量,这个变量很显然是main方法的局部变量,那么这个count变量是放在哪里的呢?Java虚拟机栈这个时候就出来了。


「Java虚拟机栈是JVM为保存每个方法的局部变量而划分的内存区域,它是线程私有的,生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡」。先先来看个例子

1
2
3
4
5
6
7
public void funA(){
System.out.println("funA");
funB();
}
public void funB(){
System.out.println("funB");
}

「比如一个线程在执行funA()时,就会将funA(),就会往当前线程的Java虚拟机栈中放一个栈帧,当执行到funB()时,又会往Java虚拟机栈放一个栈帧」。如下:

image-20240622235216294

从上图可以看到,「funA栈帧与funB栈帧的出栈入栈的时机,先入后出」

  • funA方法被封装成栈帧入栈
  • funB方法被封装成栈帧入栈
  • funB方法出栈
  • funA方法出栈

「Java虚拟机栈中存放的是由一个个栈帧,【线程在执行一个方法时,便会向栈中放入一个栈帧】,每个栈帧中都拥有【局部变量表】、【操作数栈】、【动态链接】、【方法出口】等信息」

Java 虚拟机栈会出现两种异常:

  • 「StackOverFlowError」

    若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverFlowError异常。

  • 「OutOfMemoryError」

    若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError异常。

2.2.4 本地方法栈(私有)

「看过Java的一些底层API的(IO相关、socket相关等)就会知道,底层已经不是走的Java代码了,而是通过Native 方法调用操作系统的一些方法。调用这些方法也需要一个方法栈,这就是本地方法栈」。它也是线程私有的。


本地方法栈和Java虚拟机栈基本类似,只不过「Java虚拟机栈是为Java代码(字节码)使用的,而本地方法栈则是虚拟机使用到的 Native 方法时使用的」

  • 本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
  • 方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。

2.2.5 堆内存(共享)

通过上述的描述,我们知道「每个线程都会有一个自己的【程序计数器】【Java虚拟机栈】【本地方法栈】,它们的生命周期与当前线程一样」

前面我们说了,线程在执行一个方法的时候,会将这个方法的栈帧压入自己的虚拟机栈中。而在Java中,调用方法是通过对象去调的。比如

1
2
3
4
5
6
7
8
public void funA(){
User user= new User();
user.queryUserById(id);
}

public void queryUserById(String id){
System.out.println("queryUserById");
}

以上代码是一个很简答的示例,funA中有一个User对象,通过User对象调用了它的queryUserById方法。此时当前线程的虚拟机栈中就会有funA与queryUserById两个方法的栈帧。但是有个问题,new出来的User对象在哪里呢?


此时就需要使用到JVM中另外一块内存区域了【Java堆内存】,「堆内存是JVM中所管理的最大的一块内存区域,并且是所有线程共享的一块内存区域,堆内存区域的目的就是存放对象实例,在1.7的时候将【字符串常量池】【静态变量】都放进了堆中,这在上面那种图中也有体现」

看上述案例,在funA方法中创建了User对象,它们的引用关系大致可以用下图来体现:

image-20240622234205975

通过这张图我们可以看到,「当创建一个User对象的时候,User对象实际上会被放到堆内存中,而方法funA中有一个局部变量user指向了这个对象在堆内存中的地址」

因为堆内存中存放的是对象实例,因此一般我们会把堆内存设置的比较大,主要就是设置以下两个参数

  • 「-Xmx:表示堆区的起始内存」
  • 「-Xms:表示堆区的最大内存」

对象虽然放在堆内存中,但是它也不是随意乱放,我们都知道JVM是有GC的,为了高效的GC,堆内存是做了更细致的划分的,也就是常说的新生代,老年代,永久代,他们分别对应不同的GC,这部分我会在后续的文章中单独描述,这里就不展开了,反正首先知道对象实例是存放在堆中就可以了。

2.3 元空间

「从 JDK 1.8 开始,HotSpots取消了永久代,并把方法区移至元空间,元空间它位于本地内存中,而不是虚拟机内存中」

永久代从某种意义上讲并不是去掉了,只是被元空间(「Metaspace」)取代了而已

image-20240623084839710

2.3.1 元空间和永久代的不同

  • 「存储位置不同」

    「1.7之前永久代在物理上是堆的一部分,和新生代、老年代的地址是连续的,而元空间属于本地内存」

  • 「存储内容不同」

    在原来的永久代划分中,永久代用来存放类的元数据信息、静态变量以及常量池等。

    「现在类的元信息存储在元空间中,静态变量和常量池等并入堆中,相当于原来的永久代中的数据,被元空间和堆内存给瓜分了。」

2.3.2 永久代有哪些缺点?

在之前的版本中,字符串常量池存在于永久代中,在大量使用字符串的情况下,非常容易出现OOM的异常。此外,「JVM加载的class的总数,方法的大小」等都很难确定,因此对永久代大小的指定难以确定。太小的永久代容易导致永久代内存溢出,太大的永久代则容易导致虚拟机内存紧张。

  • 它的大小是在启动时固定好的——很难进行调优。-XX:MaxPermSize,设置成多少好呢?
  • HotSpot的内部类型也是Java对象:它可能会在Full GC中被移动,同时它对应用不透明,且是非强类型的,难以跟踪调试,还需要存储元数据的元数据信息(meta-metadata)。
  • 简化Full GC:每一个回收器有专门的元数据迭代器。
  • 可以在GC不进行暂停的情况下并发地释放类数据。
  • 使得原来受限于持久代的一些改进未来有可能实现

2.3.3 废除永久代的好处?

  • 废除永久代,引入元空间后,类的元数据存储在本地内存中,「元空间的最大可分配空间就是系统可用内存空间」,大大降低OOM的出现。
  • 将元数据从永久代剥离出来到Metaspace,提升对元数据的管理同时提升GC效率。

3. Jvm的分代模型

3.1 分代收集理论

分代收集理论的思想:「根据对象的生命周期将内存划分,然后进行分区管理」

目前商业虚拟机的垃圾收集器,大多都遵循【分代收集理论】进行设计, 它建立在两个分代假说之上:

  • 「弱分代假说」:绝大多数对象都是朝生夕灭的。
  • 「强分代假说」:熬过越多次数垃圾回收过程的对象就越难消亡。

这两种设计原则共同奠定了大多数垃圾收集器的一致的设计原则:「应该将Java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中存储」

把那些难以回收的对象放在一起,这个区域的垃圾回收频率就可以降低,减少垃圾回收的开销

把那些朝生夕灭的对象放在单独的区域,可以用较高的频率去回收

在Java堆划分出不同的区域之后,垃圾回收器就可以每次只回收自己负责的那一部分区域,因此也就有了我们常说的几种GC:

  • 「部分收集(Partial GC)」 :指收集的不是整个Java堆的垃圾收集, 其中又分为:

    • 「新生代收集(Minor GC/Young GC)」

      「处理新生代(Eden \ S0,S1)的垃圾回收」

    • 「老年代收集(Major GC/Old GC)」

      「只处理老年代的垃圾回收」

      目前只有CMS收集器会有单独收集老年代的行为。

    • 「混合收集(Mixed GC)」

      「整个新生代以及部分老年代的垃圾收集回收」

      目前只有G1收集器会有这种行为。

  • 「整堆收集(Full GC)」 :整个Java堆和方法区的垃圾收集

以上的几种垃圾回收器分别针对不同的区域进行垃圾回收,各种GC的原理后面在看,既然Java堆被划分为多个区域,接下来就看看堆内存的集体划分。


基于此分代收集理论,JVM将堆内存进行了进一步的划分,分为【新生代】【老年代】,每个区域由不同的GC清理。

image-20240623074740204

image-20240623075116551

3.2 新生代

在Java程序中,大多数对象的存活时间都是很短的,就比如以下这段代码

1
2
3
4
5
6
7
8
public void funA(){
User user= new User();
user.queryUserById(id);
}

public void queryUserById(String id){
System.out.println("queryUserById");
}

当线程执行了funA后,会「创建一个User对象实例存放在Java堆内存中,此时funA栈帧中局部变量user会指向该对象实例的地址」

image-20240622234205975

「当funA 方法执行完成后,funA栈帧会出栈,局部变量也就不存在了,此时User对象实例就没有被引用」了,如下:

image-20240623075334161

此时在JVM后台运行的垃圾回收线程一旦发现了没有被引用的对象实例,它就会将其回收掉,释放堆内存。

image-20240623075610562


以上这个过程,我们的User对象被创建出来就放在堆中,使用完了就立刻销毁,这种对象一般就会在堆内存的第一个区域年轻代中。

「新生代(Young Generation)」:创建之后会优先放在被存放在堆内存的年轻代中,当年轻代放不下的时候才会放在老年代,由于新生代的对象创建销毁频繁,所以Young GC也是执行的非常频繁的。


从上面的图中可以看出来,「新生代内又分三个区:一个Eden区,两个Survivor区(幸存者区)」

  • 「大部分对象在Eden区中生成,当Eden区满时,还存活的对象将被复制到两个Survivor区(其中的一个)中」
  • 「而当某个Survivor区满时,此区存活且不满足【晋升老年代】条件的对象将被复制到另外一个Survivor区」
  • 「这些对象每经历一次Minor GC,它们的年龄会加1,当年龄达到【晋升老年代阈值】后,会被被放到老年代,这个过程就是对象的晋升过程」

「晋升老年代阈值直接影响着对象在新生代中的停留时间,默认为15」

3.3 老年代

既然上面说了对象被创建后,就会优先放在年轻代中,当年龄到达晋升阈值后才会晋升,那么什么样的对象会被晋升到老年代呢?下面我们改造一下上面的案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
privatestatic Order order= new Order();
public void funA(){
User user= new User()
user.queryUserById(id);
order.queryOrderById(id);
}

public void queryUserById(String id){
System.out.println("queryUserById");
}

public void queryOrderById(String id){
System.out.println("queryOrderById");
}

上面的伪代码中,有一个静态变量order执行了order实例,user变量是存在方法区的(类的静态变量,类信息等是存在方法区的),画一下这个伪代码的图:

image-20240623080342184


注意!!!

「不要被这张图误导,order对象一开始也是存在于新生代的,但是由于它是静态变量,一般而言静态变量,只要类没有销毁都会存在于方法区中,所以它会在新生代存在一段时间,最终会晋升到老年代中」

那么方法区里面的类啥时候会被销毁呢,这个静态变量是不是会一直存在呢?答案是否定的,方法区里面的类满足以下几种情况下会被销毁。

  • 「该类所有的实例对象都已经被回收」
  • 「加载这个类的ClassLoader对象被回收了」
  • 「该类的Class对象没有任何引用」

满足以上几种情况,方法区的类会被回收,那么它的静态变量自然也就会被回收了,老年代的对象实例也就会被清理了


回来继续看以上伪代码,当我们的funA执行完毕后,funA栈帧出栈,局部变量user被清理,此时User的实例对象就失去了引用,但是此时静态变量order还在引用着Order实例对象。如下:

image-20240623080515541


由以上案例可以得出一个结论:

「年老代主要存放JVM认为生命周期比较长的对象(经过几次的Young Gen的垃圾回收后仍然存在),内存大小相对会比较大,垃圾回收也相对没有那么频繁」

3.4 为啥要分新生代和老年代?

通过上述的描述,我们已经大致清楚了,Java的堆内存的新生代与老年代分别是存储了什么,那JVM 为啥要这么分呢?这么分有什么好处呢?

其实很好理解,堆虽然是JVM中最大的一块内存区域,但是再大也是有上限的,因此jvm提供了垃圾回收算法来清理那些已经失去引用的对象,而垃圾回收需要判断对象是否已经失去了引用,就需要检查每个对象,这无疑是一笔很大的资源开销。

将堆内存进一步划分,「将那些创建之后很快就会被销毁的对象存放在新生代区域,将那些需要长期存在的对象存放在老年代区域,针对新生代/老年代分别使用不同对策垃圾回收策略,这样就不需要每次进行GC的时候都扫描所有的对象了,减小资源开销」

3.5 新生代和老年代堆内存占比

堆的内存大小是可以人为设置的,主要受物理内存大小的限制

  • 「-Xmx:表示堆区的起始内存」
  • 「-Xms:表示堆区的最大内存」

堆被分为新生代和老年代堆,新生代又被分为了三个区。这些内区域是怎么划分的呢?先来看一张图,看图说话:

image-20240623081032391

通过这张图,可以很清晰的看到新生代和老年代在堆中的占比

  • 「新生代和老年代占比」

    新生代在堆中的大小由参数【-XX:NewRatio】控制,默认-XX:NewRatio = 2,表示新生代占1 , 老年代占2,新生代占整个堆的1/3

  • 「新生代三个区的内存占比」

    新生代的三个区占比默认情况下:「Eden空间和另外两个Survivor空间占比分别为8:1:1」,受参数【-XX:SurvivorRatio】控制,默认-XX:SurvivorRatio = 8,表示Eden::S0:S1= 8:1:1

    几乎所有的java对象都在Eden区创建, 但80%的对象生命周期都很短,创建出来就会被销毁.

「JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。因此,新生代实际可用的内存空间为 9/10 的新生代空间」

4. 对象

4.1 对象的创建过程

明白了堆内存的区域划分和各个区域的内存大小,只是明白了JVM的内存如何分配,在哪里分配而已,但是我们的对象的创建于销毁还与内存回收算法有关系,因此对对象的创建后具体是怎么分配的也要有考虑,中间是否产生了内存碎片等问题。

image-20240623091246254

  • 「当我们创建一个对象的时候,这个对象会优先放在Eden区」

  • 「当Eden区放不下了,此时又需要创建新的对象,jvm的垃圾回收器就会对Eden区进行垃圾回收(Minor GC)」

    • 将Eden区没有被引用的对象进行下销毁,有引用的的对象移动到S0区,并将其年龄加1
    • 释放内存,给新的对象分配内存
  • 后面再次再次触发垃圾回收,也会做两件事

    • 「清理Eden 和 两个Survivor区中没有被引用的对象」

    • 「将S0区还有引用的对象移动到S1区,年龄加1,将S1区还有引用的对象移动到S0区,年龄加1」

      这个过程其实就是在互换S0与S1区的对象,以达到其增加年龄,并整理内存碎片的目的

  • 「当S1或者S0区的对象年龄达到阈值后,就会被移动到老年代中」

    阈值可以通过参数 -XX:MaxTenuringThreshold设置,比如 -XX:MaxTenuringThreshold = 10

  • 「当对象移动到老年代区时,老年代内存不足,也会触发GC(Minor GC)进行老年代的清理」

    如果老年代清理之后,新的对象还是放不进去,就会OOM异常

4.2 对象的内存分布

4.2.1 对象头

  • 对象自身运行数据

    MarkWord用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、同步锁信息、偏向锁标识等等。Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。

    通常我们都是使用的64位的JVM,Mark Word 在64位 JVM 中内部结构如下图:

    2023021309380618.gif

  • 类型指针

    类型指针指向对象的类元数据,虚拟机通过这个指针确定该对象是哪个类的实例。Java对象的类数据保存在方法区。

  • 数组长度(如果有)

    如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度。如果对象是数组类型,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

4.2.2 实例数据

实例数据部分存放类的属性数据信息,包括父类的属性信息。

通过示例说明每个区域具体存放哪些内容:

1
2
3
4
5
6
7
8
9
10
11
12
class Student {
private String name;
public Student(String name) {
this.name = name;
}
}
public class Demo {
public static void main(String[] args) {
Student studentA = new Student("zhangsan");
Student studentB = new Student("lisi");
}
}

2023021309380619.png

4.2.3 对齐填充

由于虚拟机要求对象起始地址必须是8字节的整数倍,所以后面有几个字节用于把对象的大小补齐至8字节的整数倍,没有特别的功能,对齐填充不是必须存在的,仅仅是为了字节对齐。

为什么必须是8个字节?

根据“计算机组成原理”,8个字节是计算机读取和存储的最佳实践。

指针压缩

1️⃣ 概念
压缩指针是一种内存优化技术,旨在减少堆内存使用量。它通过将32位和64位指针压缩为更小的大小,从而节省堆内存的使用量。

在默认情况下,32位JVM使用32位指针,64位JVM使用64位指针。这意味着每个指针都需要占用4字节或8字节的内存空间。然而,对于大多数Java应用程序来说,实际上并不需要使用如此大的内存地址空间。

2️⃣ 原理
压缩指针的原理是利用了Java对象通常是对齐的这一特点。由于对象对齐,大多数对象的偏移量是可以预测的。因此,JVM可以使用对象的偏移量来计算对象的地址,而不必使用完整的指针。

在32位JVM中,压缩指针可以将32位指针压缩为30位,这意味着每个指针只需要占用3字节的内存空间。在64位JVM中,压缩指针可以将64位指针压缩为32位,这意味着每个指针只需要占用4字节的内存空间。

为了实现压缩指针,JVM使用对象头来存储对象的偏移量。当需要访问对象时,JVM根据对象头中的偏移量计算对象的地址。这样,JVM可以使用较小的指针来定位对象,从而节省了堆内存的使用量。

2.1. 对象对齐
在内存中,Java对象通常会按照一定的规则进行对齐。对齐意味着对象的起始地址必须是某个特定值的倍数。JVM利用对象对齐的特性,可以根据对象的偏移量来计算对象的地址,而不需要使用完整的指针。

2.2. 压缩指针
JVM使用对象头来存储对象的偏移量。对象头是每个Java对象在内存中的一部分,它包含了一些元数据信息,如对象的类型和锁状态等。JVM利用对象头中存储的偏移量信息来计算对象的地址,并将指针进行压缩。

2.3. 指针压缩算法
JVM采用了不同的指针压缩算法来实现压缩指针。在32位JVM中,常用的压缩指针算法是使用32位指针的高30位来存储对象的偏移量,而低2位用于标识指针是否被压缩。在64位JVM中,常用的压缩指针算法是使用64位指针的高32位来存储对象的偏移量,而低32位用于标识指针是否被压缩。

2.4. 内存空间的节省
通过压缩指针,JVM可以显著减少堆内存的使用量。在32位JVM中,每个指针只需要占用3字节的内存空间,而在64位JVM中,每个指针只需要占用4字节的内存空间。这对于那些需要大量对象的Java应用程序来说,可以显著降低内存消耗。

3️⃣作用
压缩指针的主要作用是减少Java应用程序的堆内存使用量
通过使用较小的指针,压缩指针可以大大减少堆内存的占用空间。这对于那些需要大量对象的应用程序来说尤为重要,因为它可以显著降低内存消耗。

此外,压缩指针还可以提高内存访问的速度
较小的指针可以更容易地装入CPU的缓存中,并且可以加快内存访问的速度。这对于性能敏感的应用程序来说尤为重要,因为它可以提高应用程序的响应速度和吞吐量。

4️⃣负面影响
虽然压缩指针可以带来内存和性能方面的优势,但也可能对应用程序产生一些负面影响。以下是可能的影响:

压缩指针可能导致更频繁的垃圾收集
由于堆内存使用量减少,JVM需要更频繁地进行垃圾回收以释放不再使用的对象。这可能会增加垃圾收集的开销,并且可能会对应用程序的响应速度产生影响。

内存分配的速度可能变慢
由于压缩指针需要更多的计算来定位对象,因此内存分配的速度可能会稍微降低。这在某些需要频繁分配内存的应用程序中可能是一个问题。

压缩指针可能与某些本地库或第三方库不兼容
由于压缩指针改变了指针的大小和布局,它可能与某些依赖于指针大小和布局的本地库或第三方库不兼容。这可能需要额外的调试和适配工作。

5️⃣ 总结
综上所述,JVM的压缩指针是一项重要的内存优化技术,它可以减少Java应用程序的堆内存使用量,并可能提高内存访问的速度。然而,开发人员应该注意压缩指针可能带来的负面影响,并在具体应用场景中进行评估和决策。

4.3 对象的访问定位

需要说明的是,HotSpot 采用第二种方式,即直接指针方式来访问对象,只需要一次寻址操作,所以在性能上比句柄访问方式快一倍。它需要额外的策略来存储对象在方法区中类信息的地址。

  • 使用句柄

    堆中需要有一块叫做“句柄池”的内存空间,句柄中包含了对象实例数据与类型数据各自的具体地址信息。

    引用类型的变量存放的是该对象的句柄地址(reference)。访问对象时,首先需要通过引用类型的变量找到该对象的句柄,然后根据句柄中对象的地址找到对象。

img

  • 直接指针

    引用类型的变量直接存放对象的地址,从而不需要句柄池,通过引用能够直接访问对象。但对象所在的内存空间需要额外的策略存储对象所属的类信息的地址。

img

5. 垃圾回收算法

  1. 什么是GC?

    GC 是 Garbage Collection 的简称,中文称为“垃圾回收”。GC ,是指程序把不用的内存空间视为垃圾并回收掉的整套动作。

    GC 要做的有两件事:

    1. 找到内存空间里的垃圾;
    2. 回收垃圾,让程序能再次利用这部分空间。

    满足这两项功能的程序就是 GC。

  2. 为什么要有GC

    在没有 GC 的世界里,程序员必须自己手动进行内存管理,必须清楚地确保必要的内存空间,释放不要的内存空间。

    程序员在手动进行内存管理时,申请内存尚不存在什么问题,但在释放不要的内存空间时,就必须一个不漏地释放。这非常麻烦,容易发生下面三种问题:内存泄露,悬垂指针,错误释放引发 BUG。

    1. 如果忘记释放内存空间,该内存空间就会发生内存泄露(内存空间在使用完毕后未释放),即无法被使用,但它又会持续存在下去。如果将发生内存泄露的程序放着不管,总有一刻内存会被占满,甚至还可能导致系统崩溃。
    2. 在释放内存空间时,如果忘记初始化指向已经回收的内存地址(对象已释放)的指针,这个指针就会一直指向释放完毕的内存空间。此时,这个指针处于一种悬挂的状态,我们称其为“悬垂指针”(dangling pointer)。如果在程序中错误地引用了悬垂指针, 就会产生无法预期的 BUG。
    3. 一旦错误释放了使用中的内存空间,下一次程序使用此空间时就会发生故障。大多数情况下会发生段错误,运气不好的话还可能引发恶性 BUG,甚至引发安全漏洞。

    为了省去上述手动内存管理的麻烦,人们钻研开发出了 GC。如果把内存管理交给计算机, 程序员就不用去想着释放内存了。

    当然,技术领域的不变法则就是万事皆有代价,GC 也会带来一些麻烦,比如后台程序需要耗费一定的 CPU 和内存资源去释放内存,在系统繁忙的情况下会对业务程序性能造成一定的不利影响。为了解决 GC 带来的问题,最近几年出现了一门新的没有 GC 的 Rust 语言,大有替代 C 语言的趋势,不过学习曲线比较陡峭,感兴趣的同学可以自行钻研。

  3. GC相关的基本术语

    1. 对象、指针、活动对象、非活动对象、堆、根

      GC 操作的基本单元可以叫做对象。对象是内存空间的某些数据的集合。在本文中,对象由头(header)和域(field)构成。

      对象的头,主要包含对象的大小、种类信息。对象中可访问的部分称为“域”,可以认为是 C 语言中结构体的成员变量。

      图片

      指针是指向内存空间中某块区域的值。GC 是根据对象的指针指向去搜寻其他对象的。

      图片

      我们将内存空间中被其他对象通过指针引用的对象成为活动对象,没有对象引用的对象是非活动对象,也就是 GC 需要回收的垃圾。

      图片

      根(root)是“根基”“根底”。在 GC 的世界里,根是指向对象的指针的“起点” 部分。堆指的是用于动态(也就是执行程序时)存放对象的内存空间。当应用程序申请存放对象时, 所需的内存空间就会从这个堆中被分配给应用程序。

      图片

    2. GC算法性能的评价标准

      • 吞吐量

        GC 的吞吐量是:运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。

      • 最大暂停时间

        因执行 GC 而暂停执行应用程序的最长时间

      • 堆使用效率

        程序在运行过程中,单位时间内能使用的堆内存空间的大小。

        堆使用效率和吞吐量,以及最大暂停时间不可兼得。简单地说就是:可用的堆越大,GC 运行越快;相反,越想有效地利用有限的堆,GC 花费的时间就越长。

      • 访问的局部性

        计算机上有 4 种存储器,分别是寄存器、缓存、内存、辅助存储器。

        图片

        众所周知,越是可实现高速存取的存储器容量就越小。计算机会尽可能地利用较高速的存储器,但由于高速的存储器容量小,不可能把所有要利用的数据都放在寄存器和高速缓存里。一般我们会把所有的数据都放在内存里,当 CPU 访问数据时,仅把要使用的数据从内存读取到缓存。由于数据是分块读取,我们还将它附近的所有数据都读取到高速缓存中, 从而压缩读取数据所需要的时间。这种内存空间中相邻的数据很可能存在连续访问因而带来访问效率提升的情况,称为“访问的局部性”。

        部分 GC 算法会利用这种局部性原理,把具有引用关系的对象安排在堆中较近的位置,就能提高在缓存 Cache 中读取到想要的数据的概率,令应用程序高速运行。

5.1 如何确定垃圾?

  • 引用计数法

    在 Java 中,引用和对象是有关联的。如果要操作对象则必须用引用进行。因此,很显然一个简单的办法是通过引用计数来判断一个对象是否可以回收。简单说,即一个对象如果没有任何与之关联的引用,即他们的引用计数都不为 0,则说明对象不太可能再被用到,那么这个对象就是可回收对象。

  • 可达性分析算法

    为了解决引用计数法的循环引用问题,Java 使用了可达性分析的方法。通过一系列的“GC roots”对象作为起点搜索。如果在“GC roots”和一个对象之间没有可达路径,则称该对象是不可达的。

    要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍然是可回收对象,则将面临回收。

    GC Roots的对象包括:

    • 栈帧中的局部变量表中的reference引用所引用的对象
    • 方法区中static静态引用的对象
    • 方法区中final常量引用的对象
    • 本地方法栈中JNI(Native方法)引用的对象
    • Java虚拟机内部的引用, 如基本数据类型对应的Class对象, 一些常驻的异常对象(比如 NullPointExcepiton、 OutOfMemoryError) 等,
    • 系统类加载器。所有被同步锁(synchronized关键字) 持有的对象。
Java四种引用类型
  • 强引用

    在 Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到 JVM 也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之一。

  • 软引用

    软引用需要用 SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。

  • 弱引用

    弱引用需要用 WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。

  • 虚引用

    虚引用需要 PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。

三色标记法

1、什么是三色标记法

增量式垃圾回收=三色标记法。

增量式垃圾回收(Incremental GC)是一种通过逐渐推进垃圾回收来控制应用程序最大暂停时间的方法。

图片

三色标记法(Tri-color Marking)是根据对象是否被垃圾收集器扫描过而用「白、灰、黑三种颜色来标记对象的状态」的一种方法,并且三色标记法根据可达性分析,从GC Roots开始进行遍历访问。

  • 「白色」

    表示该对象「尚未被垃圾收集器访问过」

    在可达性分析刚刚开始阶段,「所有的对象都是白色的」,若在分析结束之后对象仍然为白色,则表示这些对象为不可达对象,对这些对象进行回收。

    image-20240623103324477

  • 「灰色」

    表示「当前对象已经被垃圾收集器访问过,但是这个对象至少存在一个引用的其他对象还没有被扫描过」

    当前对象直接可达对象没有全部被访问,但是本对象引用到的其他对象尚未全部访问完,「全部扫描后,会转换为黑色」

    image-20240623103423433

    上图中可达性分析扫描到了A对象,但是由于A对象还存在一个没有被扫描到的B对象,因此此时A对象是灰色的。

  • 「黑色」

    「表示对象已经被垃圾收集器访问过,且这个对象的所有引用的其他对象都已经被扫描过」

    image-20240623103518882

    如上图,对象A所引用的对象B也被扫描到了,此时A所有引用的对象都扫描到了,并且A被GC Root 引用,是可达的,因此此时A是黑色的,B是灰色的。

黑色表示该对象扫描后依然存活,是可达性对象,如果有其他对象引用指向了黑色对象,无须重新扫描,「黑色对象不可能不经过灰色对象直接指向某个白色对象」

2、三色标记的过程

jvm中三色标记算法大致通过可达性分析算法,将对象分为白、灰、黑三类,这里我用三种集合【白色集合】【灰色集合】【黑色集合】将三类对象分开,具体流程如下:

  • 「步骤1:初始时,所有对象都没有被垃圾收集器访问过,这些对象都在 【白色集合】中」

  • 「步骤2:首先从GC Roots开始标记,将它们所有直接引用对象变成灰色并挪到 【灰色集合】中,自己本身变为黑色」

    「注意:GC Roots对象本身不是垃圾」

  • 「步骤3:从【灰色集合】中获取每个对象,挨个继续分析」

    • 「将当前分析对象 引用到的 其他对象 全部挪到 【灰色集合】中」
    • 「将当前分析对象 挪到 【黑色集合】里面」
  • 「步骤4:重复步骤3,直至【灰色集合】为空时结束」

  • 「结束后,仍在【白色集合】的对象即为GC Roots 不可达对象,可以进行回收」

以下是一张动态图,可以直观的表达上述过程

图片

3、三色标记的优缺点

  • 优点

    「有的垃圾回收器(CMS,G1)为了减少STW,JVM将比较耗时的标记阶段,变成了并发标记,在并发标记的同时,用户线程也可以继续运行」

    「也因为用户线程和垃圾回收线程同时运行,也就是并发标记时,对象间的引用可能发生变化,因此三色标记有两个很明显的缺陷多标和漏标」

    • 「多标:一个本应该是垃圾的对象被标记为非垃圾」

    • 「漏标:一个本应该不是垃圾的对象被标记为垃圾」

  • 缺点

    用到了写入屏障,就会增加额外负担。既然有缩短最大暂停时间的优势,吞吐量也一般。

3.1、多标

「多标也就是常说的【浮动垃圾】,指一个垃圾对象,却被标记成了可达的,不可回收的对象」

image-20240623103701244

如上图,由于用户线程和垃圾回收线程并发运行,「在垃圾回收线程将对象A标记为黑色(表示A不是垃圾,不会被清除)后,用户线程执行了gcRoot.a a = null;,此时A对象失去了引用,将A 变成了一个垃圾对象,但是它又不会被回收清除,这就是多标,这种问题称之为浮动垃圾」

浮动垃圾的问题影响不大,即使本次不清理,下次GC也会被清理,而且在并发清理阶段也会产生所谓的浮动垃圾,因为用户线程也在不断地断开引用,影响不大。

3.2、漏标

「漏标也就是错杀问题,它的特点是被标记为垃圾的对象,变成了非垃圾」。其过程如下

「漏标不同于浮动垃圾,如果一个非垃圾对象,变成了垃圾,后果就比较严重了」

  • 当可达性分析完了A后,将A和C对象标记为了黑色,并且已经分析了B对象,但是B的引用对象D还没来得及分析,此时B是灰色。

    image-20240623104008784

  • 此时发线程切换,操作系统调度用户线程来运行,而用户线程执行A.d = D;B.d = null,此时引用关系就变成了下图

    image-20240623104043482

  • 紧接着又发生了一次线程切换,操作系统调度垃圾回收线程来运行,GC线程重新开始运行,按照之前的流程继续走,发现B没有直接引用的对象 ,那么B对象变成了黑色。

    image-20240623104114119

所有对象分析完毕,「从图中就可以看出,A、B、C三个对象都是黑色,不会被回收,对象D是白色,它会被回收,但是D被A引用着,理论上是不能回收的,这就是问题所在了,也就是漏标了对象D,造成错杀问题」,这是比较严重的。

4、三色标记的缺点如何弥补

其实对于浮动垃圾,上面说了处不处理无所谓,第二次垃圾回收就会将它回收掉,所以我们一般关注怎么处理漏标的问题就可以了。而从上述的分析过程中,可以得出一个结论。「漏标需要存在以下两个条件同时满足才会发生」

  • 条件一:JVM运行过程中「插入了一条或者多条从黑色对象到白色对象的引用」

    并发标记过程中黑色对象(A)引用了白色对象(F)

  • 条件二:JVM运行过程中「删除了所有的从灰色对象到白色对象的直接引用或者间接引用」

    灰色对象(B)断开了同一个白色对象(F)引用

清楚了漏标产生的原因,那就好办了了,想办法破坏其中一个条件就行了,破坏以上两个条件的方案也有两种:「增量更新和原始快照」

4.1、读写屏障

增量更新和原始快照其原理其实差不多,都需要用到一个概念:「读写屏障,可以简单理解为在读写操作前后分别插入一段代码,将一些信息记录保存下来,跟Java里面的AOP概念类似」

4.2、读写屏障+增量更新

「增量更新即增加引用,很明显是冲着条件一来的,读写屏障 + 增量更新可以破坏条件一,从而避免漏标的情况」

读写屏障 + 增量更新 这是CMS处理漏标的方式,

  • 「原理」

    「增量更新就是在赋值操作前添加一个写屏障,在写屏障中记录新增的引用」

    在上面案例中,用户线程执行:「A.d = D 的同时也在写屏障中将这个引用关系记录下来。然后在重新标记阶段,再通过记录的引用关系的黑色对象为根,再扫描一次,以保证不会漏标」

  • 「实现方式」

    「重新标记阶段,发现读写屏障中记录了A.d = D,则直接把A对象变为灰色,放入灰色集合中,然后A对象会被重新分析一次,由于重新标记阶段是STW的,所以就可以避免漏标了」

    增量更新导致A对象被分析了两次,在一定程度上其实是影响了效率的。

4.3、读写屏障+原始快照

「原始快照(Snapshot At The Beginning)简称SATB,它是冲着条件二来的,在灰色对象减少引用时起作用」

读写屏障 + 原始快照 这是G1处理漏标的方式,

  • 「原理」

    「在灰色对象进行赋值操作时,同样添加一个写屏障,记录赋值删除的引用」

    在上面案例中,用户线程执行:「B.d = null 之前写屏障中将B.d = D 这个引用关系记录下来后在进行置空操作,也就是记录下来原来的引用关系,即原始快照」

    「然后在重新标记阶段,再通过记录的引用关系的黑色对象为根,再扫描一次,以保证不会漏标」

  • 「实现方式」

    「在用户线程切换为垃圾回收线程后,发现读写屏障里面记录了B.d = D这个引用关系,就直接将B变成黑色,这样D对象也就会被找到,不会被回收了」

    这种方式有一个弊端:D对象有可能本来就应该是一个垃圾对象,结果却没有被回收,不过问题不大,就当做浮动垃圾等下一次GC回收就好了。

5.2 GC 标记-清除算法(Mark-Sweep)

最基础的垃圾回收算法,分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。如图:

image-20240623094842248

从图中我们就可以发现,该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。

GC标记-清除算法

1、分阶段描述

GC 标记-清除算法由标记阶段和清除阶段构成。标记阶段是把所有活动对象都做上标记的阶段。清除阶段是把那些没有标记的对象,也就是非活动对象回收的阶段。通过这两个阶段,就可以令不能利用的内存空间重新得到利用。

  • 标记阶段

    遍历对象并为堆里的所有活动对象打上标记。

    1. 执行GC前堆的状态

      image-20240623120055485

    2. 标记对象的动作

      image-20240623120108631

    3. 标记阶段结束后的堆状态

      image-20240623120123111

  • 清除阶段

    在清除阶段中,垃圾回收器 Collector 会遍历整个堆,回收没有打上标记的对象(即垃圾),使其能再次得到利用。

    1. 清除阶段结束后的堆状态:
      image-20240623120357905

    2. 在清除阶段中,垃圾回收器 Collector 会遍历整个堆,回收没有打上标记的对象(即垃圾),使其能再次得到利用。

      在清除阶段,GC 程序会遍历堆,具体来说就是从堆首地址开始,按顺序一个个遍历对象的标志位。如果一个对象设置了标记位,就说明这个对象是活动对象,必然是不能被回收的。

      GC 程序会把非活动对象回收再利用。回收对象就是把对象作为分块,连接到被称为“空闲链表”的单向链表。在之后进行分配时只要遍历这个空闲链表,就可以找到分块了。

      在清除阶段,程序会遍历所有堆,进行垃圾回收。也就是说,所花费时间与堆大小成正 比。堆越大,清除阶段所花费的时间就会越长。

      在 GC 的标记-清除过程中,还会不断进行的两个动态操作那就是分配和合并。

  • 分配和合并

    在 GC 的标记-清除过程中,还会不断进行的两个动态操作那就是分配和合并。

    • 分配

      分配是指将回收的垃圾进行再利用。

      GC 程序在清除阶段已经把垃圾对象连接到空闲链表了。当应用程序创建新对象时,搜索空闲链表并寻找大小合适的分块,这项操作就叫作分配。

    • 合并

      根据分配策略的不同可能会产生大量的小分块。但如果它们是连续的, 我们就能把所有的小分块连在一起形成一个大分块。这种“连接连续分块”的操作就叫作合并(coalescing),合并是在清除阶段进行的。

2、优缺点

  • 优点

    1. 实现简单,很容易在基本的 GC 标记清除法基础上改进,或者容易和其他算法组合形成新的 GC 算法。
    2. GC 标记-清除算法因为不会移动对象,所以非常适合搭配保守式 GC 算法。
  • 缺点

    1. 碎片化。使用过程中会逐渐产生被细化的分块,不久后就会导致无数的小分块散布在堆的各处。

    2. 分配速度慢。GC 标记-清除算法中分块不是连续的,因此每次分配都必须遍历空闲链表,找到足够大的分块才行。

    3. 与写时复制技术(copy-on-write)不兼容。

3、改进

  • 分配速度的改进——多个空闲链表

    之前介绍的基本标记-清除算法中只用到了一个空闲链表,在这个空闲链表中,对大的分块和小的分块进行同样的处理。但是这样一来,每次分配的时候都要遍历一次空闲链表来寻找合适大小的分块,这样非常浪费时间。

    可以寻求一种改进的方法,利用分块大小不同的空闲链表,即创建只连接大分块的空闲链表和只连接小分块的空闲链表,甚至不同规格大小的分块采用不同的空闲链表管理。这样一来,只要按照应用程序所申请的对象大小选择空闲链表,就能在短时间内找到符合条件的分块了。我们知道,Golang 的内存分配里就是这么做的了。

    图片

  • 碎片化分块问题的改进——BiBOP 法

    BiBOP 是 Big Bag Of Pages 的缩写。用一句话概括就是“将大小相近的对象整理成固定大小的块进行管理的做法”。

    如下图所示。把堆分割成多个规格大小的空间,让每个规格的空间只能配置同样大小的分块。

    2个字的分块只能在最左边的堆空间里分配,3个字的分块只能在中间的堆空间分配,4个字的块在最右边。像这样配置对象,就会提高内存的使用效率。

    图片

5.3 复制算法(Copying)

为了解决 Mark-Sweep 算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉,如图:

image-20240623094914002

这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。

GC 复制算法

1、基本原理

GC 复制算法(Copying GC),就是只把某个空间里的活动对象复制到其他空间,把原空间里的所有对象都回收掉。在此,我们将复制活动对象的原空间称为 From 空间,将粘贴活动对象的新空间称为 To 空间。

GC 复制算法是利用 From 空间进行分配的。当 From 空间被完全占满时,GC 会将活动对象全部复制到 To 空间。当复制完成后,该算法会把 From 空间和 To 空间互换,GC 也就结束了。From 空间和 To 空间大小必须一致。这是为了保证能把 From 空间中的所有活动对象都收纳到 To 空间里。如下图所示:

图片

2、执行过程

  • 初始状态

    堆里 From 空间已经分配满了部分对象,对象间的引用关系如连线所示,即将开始 GC,To 空间目前没有被使用,有个空闲分块起始指针 $free 需要指向 To 空间的开头,对象复制到了 To 空间放在 $free 指向的位置。

    图片

    开始 GC 后,首先复制的是从根引用的对象 B 和 G,对象 B 先被复制到 To 空间,空闲分块起始指针 $free 移到 B 对象之后。B 被复制后生成的对象称为 B’,原对象 B 还在 From 空间,B 里保存了指向 B’的指针,因为原 From 空间还有其他对象要通过 B 找到 B’。

  • B被复制后

    图片

    目前只把 B’复制了过来,它的子对象 A 还在 From 空间里。

  • A被复制后

    因为 A 没有子对象,所以对 A 的复制也就完成了。

    图片

  • GC结束后

    我们要复制和 B 一样从根引用的 G,以及其子对象 E。虽然 B 也是 G 的子对象, 不过因为已经复制完 B 了,所以只要把从 G 指向 B 的指针换到 BꞋ 上就行了。

    最后只要把 From 空间和 To 空间互换,GC 就结束了。

    图片

从根开始搜索对象,采用的是深度优先搜索的方式。

图片

3、优缺点

  • 优点
    1. 优秀的吞吐量。GC 标记-清除算法消耗的吞吐量是搜索活动对象(标记阶段)所花费的时间和搜索整体堆(清除阶段)所花费的时间之和。因为 GC 复制算法只搜索并复制活动对象,所以跟一般的 GC 标记-清除算法相比,它能在较短时间内完成 GC。也就是说,其吞吐量优秀。
    2. 可实现内存的高速分配。GC 复制算法不使用空闲链表。这是因为分块是一个连续的内存空间。因此,调查这个分块的大小,只要这个分块大小不小于所申请的大小,那么移动空闲分块的指针就可以进行分配了。
    3. 不会发生碎片化。基于算法性质,活动对象被集中安排在 From 空间的开头。像这样把对象重新集中,放在堆的一端的行为就叫作压缩。在 GC 复制算法中,每次运行 GC 时都会执行压缩。因此 GC 复制算法有个非常优秀的特点,就是不会发生碎片化。
    4. 满足高速缓存的局部性原理。在 GC 复制算法中有引用关系的对象会被安排在堆里离彼此较近的位置。访问效率更高。
  • 缺点
    1. 堆使用效率低下。GC 复制算法把堆二等分,通常只能利用其中的一半来安排对象。也就是说,只有一半 堆能被使用。相比其他能使用整个堆的 GC 算法而言,可以说这是 GC 复制算法的一个重大的缺陷。
    2. 不兼容保守式 GC 算法。因为GC复制算法会移动对象到另外的位置,保守式GC算法要求对象的位置不能移动,这在某些情况下有一点的优势。而GC复制算法没有这种优势。
    3. 递归调用函数。复制某个对象时要递归复制它的子对象。因此在每次进行复制的时候都要调用递归函数,由此带来的额外负担不容忽视。比起这种递归算法,迭代算法更能高速地执行。此外,因为在每次递归调用时都会消耗栈,所以还有栈溢出的可能。

4、改进

  • Cheney 的 GC 复制算法

    Cheney 的 GC 复制算法说起来也没什么复杂的,就是将基本GC的深度优先搜索改为广度优先搜索。这样可以将递归复制改为迭代复制。

    图片

    Cheney 的 GC 复制算法的过程用下面几张图来描述。GC 开始前的初始状态如下图所示。只是在指向 To 空间开头的指针多了一个 $scan,用来扫描已复制对象的指针,该指针是实现广度优先搜索查找对象的关键。

    图片

    在 Cheney 的算法中,首先复制所有从根直接引用的对象,在这里就是复制 B 和 G。由于 $scan 负责对已复制完的对象进行搜索并向右移动指针,$free 负责对没复制的对象进行复制并向右移动指针,此时 $scan 仍然指着 To 空间的开头,$free 从 To 空间的开头向右移动了 B 和 G 个长度。如下图所示。

    图片

    由于根引用的两个对象 B、G 已经复制完成,接下来移动 $scan 指针搜索已复制对象 B 的子对象,然后把被 B’引用的 A 复制到了 To 空间,同时把 scan 和 $free 分别向右移动了。

    图片

    下面该搜索的是 G’。搜索 G’后,E 被复制到了 To 空间,从 G’ 指向 B 的指针被换到了 B’。

    下面该搜索 A’和 E’ 了,不过它们都没有子对象,所以即使搜索了也不能进行复制。因为在 E’ 搜索完成时 $scan 和 $free 一致,所以最后只要把 From 空间和 To 空间互换,GC 就结束了。如下图所示。

    图片

    不用递归算法而用迭代算法,可以抑制调用函数的额外负担和栈的消耗。但是,带来的缺点是有引用关系的对象在内存中没有放在一起,没有利用到高速缓存 Cache 的局部性原理,在访问效率上要打个折扣。当然,对这一问题的改进是近似深度优先搜索方法,这里就不展开了。

  • 多空间复制算法

    GC 复制算法最大的缺点是只能利用半个堆。这是因为该算法将整个堆分成了两半,每次都要腾出一半。多空间复制算法可以改进 GC 复制算法“只能利用半个堆”的问题。

    多空间复制算法说白了就是把堆 N 等分,对其中 2 块空间执行 GC 复制算法,对剩下的(N-2)块空间执行 GC 标记-清除算法,也就是把这 2 种算法组合起来使用。

    下面用四张图来说明多空间复制算法的执行过程。

    首先将堆划分成四个大小相同的子空间,分别用 heap[0],heap[1],heap[2],heap[3] 来表示。

    第1次执行 GC 之前,是 heap[0] 作为 To 空间,heap[1] 作为 From 空间,可以分配活动对象。heap[2] 和 heap[3] 也可以分配对象,不过是采用标记-清除算法,它们的空闲分块用空闲链表链接起来。

    图片

    第1次 GC 之后,作为 From 空间的 heap[1] 的活动对象复制到了作为 To 空间的 heap[0] 中。

    图片

    接下来,将 To 空间和 From 空间分别向右移动一个位置,将 heap[1] 作为 To 空间,将 heap[2] 作为 From 空间。此时,新对象可以分配在作为 From 空间的 heap[2],heap[0] 和 heap[3] 采用标记-清除算法,同样可以分配新对象。

    图片

    如果作为 From 空间的 heap[2],heap[0] 和 heap[3] 三个空间又满了,需要执行第2次 GC。此时,会把 From 空间的活动对象复制到作为 To 空间的 heap[1] 中,第2次 GC 结束之后的堆状态如下图所示。

    图片

    优点:多空间复制算法没有将堆二等分,而是分割成了更多块空间,从而更有效地利用了堆。以往的 GC 复制算法只能使用半个堆,而多空间复制算法仅仅需要空出一个分块,不能使用 的只有 1/N 个堆。

    缺点:执行 GC 复制算法的只有N等分中的两块空间,对于剩下的(N-2)块空间执行的是 GC 标记-清除算法。因此就出现了 GC 标记-清除算法固有的问题——分配耗费时间、分块碎片化等。只要把执行 GC 标记-清除算法的空间缩小,就可以缓解这些问题。打个比方,如果让 N = 3,就能把发生碎片化的空间控制在整体堆的 1/3。不过这时候为了在剩下的 2/3 的空间里执行 GC 复制算法,我们就不能使用其中的一半,也就是堆空间的 1/3。

5.4 标记-压缩算法(Mark-Compact)

结合了以上两个算法,为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同,标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。如图:

image-20240623094942416

GC 标记-压缩算法

1、基本原理

GC 标记-压缩算法(Mark Compact GC)是将 GC 标记-清除算法与 GC 复制算法相结合的产物。

GC 标记-压缩算法由标记阶段和压缩阶段构成。在 GC 标记-压缩算法中新空间和原空间是同一个空间。

压缩阶段并不会改变对象的排列顺序,只是把对象按顺序从堆各处向左移动到堆的开头。这样就缩小了它们之间的空隙, 把它们聚集到了堆的一端。

2、执行过程

GC 标记-压缩算法的执行过程的简化版本,如下图所示。GC 开始后,首先是标记阶段。搜索根引用的对象及其子对象并打上标记,这里采用深度优先搜索。然后将打上标记的活动对象复制到堆的开头。压缩阶段并不会改变对象的排列顺序,只是缩小了它们之间的空隙, 把它们聚集到了堆的一端。

图片

这里需要重点说明的是压缩过程。压缩过程会通过从头到尾按顺序扫描堆 3 次,第1次是对每个打上标记的对象找到它要移动的位置并记录在它们各自的成员变量 forwarding 里,第2次是重写所有活动对象的指针,将它们指向原位置的指针改为指向压缩后的对象地址,第3次是搜索整个堆,将活动对象移动到 forwarding 指针指向的位置完成对象的移动。

下面依次说明。

如下图所示,是第1次顺序扫描堆,对每个打上标记的对象,找到它要移动的位置并记录在它们各自的成员变量 forwarding 里。

图片

第2次扫描堆,更新重写所有活动对象的指针,将它们指向原位置的指针改为指向压缩后的对象地址,如下图所示。

图片

第3次扫描堆,移动活动对象到其目的地址,完成对象的压缩过程。

图片

三次堆扫描完成后,GC 整个过程结束。GC 结束状态如下图所示。

图片

3、优缺点

  • 优点
    1. 可有效利用堆。GC 标记-压缩算法和其他算法相比而言,堆利用效率高。GC 标记-压缩算法不会出现 GC 复制算法那样只能利用半个堆的情况。GC 标记-压缩算法可以在整个堆中安排对象,堆使用效率几乎是 GC 复制算法的 2 倍。
    2. 没有 GC 标记-清除法所带来的碎片化问题。
  • 缺点
    1. 压缩花费计算成本。压缩有着巨大的好处,但也有相应的代价。必须对整个堆进行 3 次搜索。执行该算法所花费的时间是和堆大小成正比的。
    2. GC 标记-压缩算法的吞吐量要劣于其他算法。

4、改进

  • 减少堆搜索次数的 Two-Finger 二指算法

    Two-Finger 算法有着很大的制约条件,那就是“必须将所有对象整理成大小一致”。

    在基本的 GC 标记-压缩算法中,通过执行压缩操作使活动对象往左边滑动。而在 Two-Finger 算法中,是通过执行压缩操作来让活动对象填补空闲空间。此时为了让对象能恰好填补空闲空间, 必须让所有对象大小一致。

    Two-Finger 二指算法中对象的移动过程,如下图所示。主要用到了两个指针,空闲分块指针 $free 和活动对象指针 $live,前者从左往右找,后者从右往左找。当 $free 找到了空闲分块,$live 找到了活动对象,则将活动对象移动到空闲分块 $free 的位置,实现对象的移动。

    图片

    优点:Two-Finger 二指算法,压缩需要的搜索次数只有 2 次,在吞吐量方面占优势。

    缺点:压缩移动对象时没有考虑把有引用关系的对象放在一起,无法利用高速缓存基于局部性原理提升访存效率。该算法还有一个限制条件,那就是所有对象的大小必须一致,导致应用受限。不过和第3.1节介绍的要求“将大小相近的对象整理成固定大小的块进行管理的做法”的 BiBOP 算法结合起来使用,会起到珠联璧合的效果。

保守式GC

0、什么是保守式 GC

保守式 GC(Conservative GC)指的是“不能识别指针和非指针的 GC”。

如下图所示,在 C/C++ 等高级语言的早期 GC 程序里,如果寄存器、函数调用栈或全局变量空间等这些根空间里有一个数值型的变量0x00d0caf0和一个指针的地址是相同的值0x00d0caf0,则程序无法识别这个值到底是数值变量还是指针。

图片

对于貌似指针的非指针,为了避免错误回收导致程序故障,采取“宁可放过,不可杀错”的宽容原则,把它们当做活动对象而保留下来,像这样,在运行 GC 时采取的是一种保守的态度,即“把可疑的东西看作指针,稳妥处理”,所以我们称这种方法为“保守式 GC ”。

保守式 GC 的特点是尽量不移动对象的位置,因为容易把非指针重写从而产生意想不到的 BUG。

当然,大部分高级程序语言如 Java、Golang 在语言设计之初就是强类型语言,不存在无法识别变量和指针的问题,它们采用的就是跟保守式 GC 相对应的准确式 GC。

1、优缺点

  • 优点

    容易编写语言处理程序(语言处理程序是指将源程序转换成机器语言、以便计算机能够运行的汇编程序、编译程序和解释程序)。处理程序基本上不用在意 GC 就可以编写代码。语言处理程序的实现者即使没有意识到 GC 的存在,程序也会自己回收垃圾。因此语言处理程序的实现要比准确式 GC 简单。

  • 缺点

    1. 识别指针和非指针需要付出成本。在跟空间里,变量和指针的值相同的情况下,程序需要额外通过是否内存对齐、是否指向堆内对象的开头等手段来判断指针和非指针,成本较高。
    2. 错误识别指针会压迫堆。当存在貌似指针的非指针时,保守式 GC 会把被引用的对象错误识别为活动对象。如果这个对象存在大量的子对象,那么它们一律都会被看成活动对象。这样容易留下较多的垃圾对象,从而会严重压迫堆。
    3. 能够使用的 GC 算法有限。由于保守式GC的特点是尽量不移动对象的位置,因为容易把非指针重写从而产生意想不到的BUG。所以基本上不能使用 GC 复制算法等移动对象的 GC 算法。

2、改进

  • 准确式 GC

    准确式 GC(Exact GC)和保守式 GC 正好相反,它是能正确识别指针和非指针的 GC。

    要能精确地识别指针和非指针,需要依赖程序语言设计之初的语言处理程序的支持。

    大部分高级程序语言如 Java、Golang,在语言设计之初就是强类型语言,不存在无法识别变量和指针的问题,它们采用的就是跟保守式 GC 相对应的准确式 GC。

    优点:不会错误识别指针,不会将已经死了的对象识别为活动对象,因此GC回收垃圾会比较彻底。还可以使用GC复制算法等需要移动对象的算法,提高GC的吞吐量和效率。

    缺点:当创建准确式 GC 时,语言处理程序(语言处理程序是指将源程序转换成机器语言、以便计算机能够运行的汇编程序、编译程序和解释程序)必须对 GC 进行一些支援。也就是说,在创建语言处理程序时必须顾及 GC。增加了语言处理程序的实现复杂度。

  • 间接引用

    保守式 GC 有个缺点,就是“不能使用 GC 复制算法等移动对象的算法”。解决这个问题的方法之一就是“间接引用”。

    根和对象之间通过句柄连接。每个对象都有一个句柄,它们分别持有指向这些对象的指针。并且局部变量和全局变量没有指向对象的指针,只装着指向句柄的指针。当应用程序操作对象时,要通过经由句柄的间接引用来执行。

    只要采用了间接引用,那么即使移动了引用目标的对象,也不用改写关键的值——根里面的值,改写句柄里的指针就可以了。也就是说,我们只要采用间接引用来处理对象, 就可以移动对象。如下图所示,在复制完对象之后,根的值并没有重写。

    图片

    优点:因为在使用间接引用的情况下有可能实现 GC 复制算法,所以可以得到 GC 复制算法所带来的好处,例如消除碎片化等。

    缺点:因为必须将所有对象都(经由句柄)间接引用,所以会降低访问对象内数据的速度,这会关系到整个语言处理程序的速度。

  • MostlyCopyingGC 大部分复制算法

    MostlyCopyingGC 就是“把那些不明确的根指向的对象以外的对象都复制的 GC 算法”。Mostly 是“大部分”的意思。说白了,MostlyCopyingGC 就是抛开那些不能移动的对象,将其他“大部分”的对象都进行复制的 GC 算法。

5.5 分代收集算法

分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将 GC 堆划分为老生代(Tenured/Old Generation)和新生代(Young Generation)。老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。

  • 优缺点

    • 优点:

      “很多对象年纪轻轻就会死”这一经验适合大多数情况,新生代 GC 只将刚生成的对象当成对象,这样一来就能减少时间上的消耗。分代垃圾回收可以改善 GC 所花费的时间(吞吐量)。“据实验表明,分代垃圾回收花费的时间是 GC 复制算法的 1/4。”

    • 缺点:

      “很多对象年纪轻轻就会死”这个法则毕竟只适合大多数情况,并不适用于所有程序。对对象会活得很久的程序执行分代垃圾回收,就会产生以下两个问题。

      • 新生代 GC 所花费的时间增多。
      • 老年代 GC 频繁运行。
  • 改进

    • 多代垃圾回收

      分代垃圾回收将对象分为新生代和老年代,通过尽量减少从新生代晋升到老年代的对象, 来减少在老年代对象上消耗的垃圾回收的时间。

      基于这个理论,大家可能会想到分为 3 代或 4 代岂不更好?这样一来能晋升到最老一代的对象不就更少了吗?这种方法就叫作多代垃圾回收(Multi-generational GC)。

      图片

      在这个方法中,除了最老的那一代之外,每代都有一个记录集。X 代的记录集只记录来自比 X 老的其他代的引用。

      分代数量越多,对象变成垃圾的机会也就越大,所以这个方法确实能减少活到最老代的对象。

      但是我们也不能过度增加分代数量。分代数量越多,每代的空间也就相应地变小了,这样一来各代之间的引用就变多了,各代中垃圾回收花费的时间也就越来越长了。

5.5.1 新生代与复制算法

目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要回收大部分对象,即要复制的操作比较少,但通常并不是按照 1:1 来划分新生代。一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中。

image-20240623095025804

5.5.2 老年代与标记复制算法

而老年代因为每次只回收少量对象,因而采用 Mark-Compact 算法。

  1. JAVA 虚拟机提到过的处于方法区的永生代(Permanet Generation),它用来存储 class 类,常量,方法描述等。对永生代的回收主要包括废弃常量和无用的类。
  2. 对象的内存分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目前存放对象的那一块),少数情况会直接分配到老生代。
  3. 当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC,进行 GC 后,Eden Space 和 From Space 区的存活对象会被挪到 To Space,然后将 Eden Space 和 From Space 进行清理。
  4. 如果 To Space 无法足够存储某个对象,则将这个对象存储到老生代。
  5. 在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。
  6. 当对象在 Survivor 区躲过一次 GC 后,其年龄就会+1。默认情况下年龄到达 15 的对象会被移到老生代中。

5.5.3 分代垃圾回收的基本原理

分代垃圾回收算法把对分成了四个空间,分别是生成空间、2 个大小相等的幸存空间以及老年代空间。新生代对象会被分配到新生代空间,老年代对象则会被分配到老年代空间里。

图片

应用程序创建的新对象一般会放到新生代空间里,当生成空间满了的时候,新生代 GC 就会启动,将生成空间中的所有活动对象复制,这跟 GC 复制算法是一个道理,复制的目标空间是幸存空间。

2 个幸存空间和 GC 复制算法里的 From 空间、To 空间很像,我们经常只利用其中的一个。在每次执行新生代 GC 的时候,活动对象就会被复制到另一个幸存空间里。在此我们将正在使用的幸存空间作为 From 幸存空间,将没有使用的幸存空间作为 To 幸存空间。

新生代 GC 也必须复制生成空间里的对象。也就是说,生成空间和 From 幸存空间这两个空间里的活动对象都会被复制到 To 幸存空间里去——这就是新生代 GC。

只有从一定次数的新生代 GC 中存活下来的对象才会得到晋升,也就是会被复制到老年代空间去。

在执行新生代 GC 时需要注意,需要考虑到从老年代空间到新生代空间的引用。新生代对象不只会被根和新生代空间引用,也可能被老年代对象引用。因此,除了一般 GC 里的根,还需要将从老年代空间的引用当作根(像根一样的东西)来处理。

这里,使用记录集用来记录从老年代对象到新生代对象的引用。这样在新生代 GC 时就可以不搜索老年代空间的所有对象,只通过搜索记录集来发现从老年代对象到新生代对象的引用。

图片

图片

图片

通过新生代 GC 得到晋升的对象把老年代空间占满后,就要执行老年代 GC 了。老年代 GC 没什么难的地方,它只用到了GC 标记-清除算法。

6. GC垃圾收集器

image-20240623094450281

6.1 Serial垃圾收集器(单线程、复制算法)

Serial(英文连续)是最基本垃圾收集器,使用复制算法,曾经是JDK1.3.1 之前新生代唯一的垃圾收集器。Serial 是一个单线程的收集器,它不但只会使用一个 CPU 或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。

Serial 垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个 CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此 Serial垃圾收集器依然是 java 虚拟机运行在 Client 模式下默认的新生代垃圾收集器。

6.2 ParNew垃圾收集器(Serial+多线程)

ParNew 垃圾收集器其实是 Serial 收集器的多线程版本,也使用复制算法,除了使用多线程进行垃圾收集之外,其余的行为和 Serial 收集器完全一样,ParNew 垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程。

ParNew 收集器默认开启和 CPU 数目相同的线程数,可以通过-XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。【Parallel:平行的】

ParNew虽然是除了多线程外和Serial 收集器几乎完全一样,但是ParNew垃圾收集器是很多 java虚拟机运行在 Server 模式下新生代的默认垃圾收集器。

6.3 Parallel Scavenge收集器(多线程复制算法、高效)

Parallel Scavenge 收集器也是一个新生代垃圾收集器,同样使用复制算法,也是一个多线程的垃圾收集器,它重点关注的是程序达到一个可控制的吞吐量(Thoughput,CPU 用于运行用户代码的时间/CPU 总消耗时间,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)),

高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。自适应调节策略也是 ParallelScavenge 收集器与 ParNew 收集器的一个重要区别。

6.4 Serial Old收集器(单线程标记整理算法)

Serial Old 是 Serial 垃圾收集器年老代版本,它同样是个单线程的收集器,使用标记-整理算法,这个收集器也主要是运行在 Client 默认的 java 虚拟机默认的年老代垃圾收集器。

在 Server 模式下,主要有两个用途:

  1. 在 JDK1.5 之前版本中与新生代的 Parallel Scavenge 收集器搭配使用。

  2. 作为年老代中使用 CMS 收集器的后备垃圾收集方案。

新生代 Serial 与年老代 Serial Old 搭配垃圾收集过程图:

image-20240623094356063

新生代 Parallel Scavenge 收集器与 ParNew 收集器工作原理类似,都是多线程的收集器,都使用的是复制算法,在垃圾收集过程中都需要暂停所有的工作线程。新生代 Parallel Scavenge/ParNew 与年老代 Serial Old 搭配垃圾收集过程图:

image-20240623094345564

6.5 Parallel Old收集器(多线程标记整理算法)

Parallel Old 收集器是Parallel Scavenge的年老代版本,使用多线程的标记-整理算法,在 JDK1.6才开始提供。

在 JDK1.6 之前,新生代使用 ParallelScavenge 收集器只能搭配年老代的 Serial Old 收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old 正是为了在年老代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,可以优先考虑新生代 Parallel Scavenge和年老代 Parallel Old 收集器的搭配策略。

新生代 Parallel Scavenge 和年老代 Parallel Old 收集器搭配运行过程图:

image-20240623094330216

6.6 CMS收集器(多线程标记清除算法)

Concurrent mark sweep(CMS)收集器是一种年老代垃圾收集器,其最主要目标是获取最短垃圾回收停顿时间,和其他年老代使用标记-整理算法不同,它使用多线程的标记-清除算法。

最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验。

CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下 4 个阶段:

  1. 初始标记

    只是标记一下 GC Roots 能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。

  2. 并发标记

    进行 GC Roots 跟踪的过程,和用户线程一起工作,不需要暂停工作线程。

  3. 重新标记

    为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程。

  4. 并发清除

    清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行。

CMS 收集器工作过程:

image-20240623094308006

6.7 G1收集器

Garbage first 垃圾收集器是目前垃圾收集器理论发展的最前沿成果,相比与 CMS 收集器,G1 收集器两个最突出的改进是:

  1. 基于标记-整理算法,不产生内存碎片。

  2. 可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。

G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分和优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率。

7. Java从编译到执行的过程

对象的创建过程

1、类加载检查
当需要创建一个类的实例对象时,比如通过new xxx()方式,虚拟机首先会去检查这个类是否在常量池中能定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化,如果没有,那么必须先执行类的加载流程;如果已经加载过了,就不会再次加载。

Q:为什么在对象创建时,需要有这一个检查判断?
A:主要原因在于:类的加载,通常都是懒加载,只有当使用类的时候才会加载,所以先要有这个判断流程。

2、分配内存
类加载成功后,虚拟机就能够确定对象的大小了,此时虚拟机会在堆内存中划分一块对象大小的内存空间出来,分配给新生对象。
虚拟机如何在堆中分配内存主要有两种方式:

  • 指针碰撞法

  • 空闲列表法

    下面我们一起来看看相关的内存分配方式。

2.1、指针碰撞法

如果内存是规整的,那么虚拟机将采用指针碰撞法来为对象分配内存。指针碰撞法,简单的说就是所有用过的内存在一边,空闲的内存在另一边,中间放着一个指针作为分界点的指示器,分配内存时会把指针向空闲一方挪动一段,直到能容纳对象大小的位置。
如果垃圾收集器选择的是 Serial、ParNew 这种基于压缩算法的,虚拟机会采用这种分配方式。

2.2、空闲列表法

如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用空闲列表法来为对象分配内存。空闲列表法,简单的说就是在虚拟机内部维护了一个列表,会记录哪些内存块是可用的,在分配的时候会从列表中找到一块能容纳对象大小的空间,划分给对象实例,并更新列表上的内容。
如果垃圾收集器选择的是 CMS 这种基于标记-清除算法的,虚拟机会采用这种分配方式。

2.3、内存分配安全问题

我们知道,虚拟机是支持多个线程同时分配内存的,是否会有线程安全的问题呢?
答案是:肯定存在的。比如用指针碰撞法时,虚拟机正在给对象 A 分配内存,但指针还没来及修改,此时又有一个线程给对象 B 分配内存,同时使用了原来的指针来分配,最后的结果就是这个区域只分配来一个对象,另一个对象被覆盖了。
针对内存分配时存在的线程安全问题,虚拟机采用了两种方式来进行处理:

  • CAS+重试机制:通过 CAS 操作移动指针,只有一个线程可以移动成功,移动失败的线程重试,直到成功为止
  • TLAB (thread local Allocation buffer):也称为本地线程分配缓冲,这个处理方式思想很简单,就是当线程开启时,虚拟机会为每个线程分配一块较大的空间,然后线程内部创建对象的时候,就从自己的空间分配,这样就不会有并发问题了,当线程自己的空间用完了才会从堆中分配内存,之后会转为通过 CAS+重试机制来解决并发问题

2.4、内存结构
当JVM分配好内存后,这个对象在内存中已经存在了。以 32 位的虚拟机为例(64位的虚拟机略有不同),对象的内存结构可以用如下图来简要概括:

各部分区域功能如下:

  • 对象头:分为 Mark Word 和元数据区,如果是数组对象,还有记录数组长度的区域。这三块保存着对象的 hashCode 值,锁的状态,类元数据指针,对象的分代年龄等等信息;
  • 实例数据:顾名思义,用于保存对象成员变量的值,如果变量是引用类型,保存的是内存地址;
  • 对齐填充位:因为 HotSpot 虚拟机要求对象的起止地址必须是 8 字节的整数倍,也就是要求对象的大小为 8 字节的整数倍,如果不足 8 字节的整数倍,那么就需要通过对齐填充进行占位,补够 8 字节的整数倍。
    注意:其实到内存分配这一步完成,这个对象算已经创建好了,但只是个雏形,还不能使用。

2.3、初始化零值
初始化零值,顾名思义,就是对分配的这一块内存(实例数据区)初始化零值,也就是给对象的实例成员变量赋于零值,比如 int 类型赋值为 0,引用类型为null等操作。这样对象就可以在没有赋值情况下使用了,只不过访问对象的成员变量都是零值。

2.4、 设置头对象
初始化零值完成之后,虚拟机就会对对象进行必要的设置,比如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息,这些信息都会存放在对象头中。这部分数据,官方称它为“Mark Word”。
在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头 (Header)、 实例数据 (Instance Data) 和对齐填充位 (Padding)。

我们来看下 Mark Word 的组成,不同的操作系统环境下占用的空间不同,在 32 位操作系统中占 4 个字节,在 64 位中占 8 个字节。

以 32 位操作系统为例,Mark Word 内部结构如下:

在这里插入图片描述

各部分的含义如下:

  • identity_hashcode:25 位的对象标识哈希码。采用延迟加载技术,调用System.identityHashCode()方法获取,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程 Monitor 中;
  • age:4 位的 Java 对象年龄。在GC中,如果对象在 Survivor 区复制一次,年龄增加 1,当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行 GC 的年龄阈值为 15,并发 GC 的年龄阈值为 6。由于 age 只有4位,所以最大值为15,这就是为什么-XX:MaxTenuringThreshold选项最大值为 15 的原因;
  • lock:2 位的锁状态标记位。对象的加锁状态分为无锁、偏向锁、轻量级锁、重量级锁等几种标记,不同的标记值,表示的含义也不同;
  • biased_lock:对象是否启用偏向锁标记,只占 1 个二进制位。为 1 时表示对象启用偏向锁,为 0 时表示对象没有偏向锁。偏向锁是一种锁的优化手段,开启偏向锁,某些时候可以省去线程频繁申请锁的操作,提升程序执行性能。;
  • thread:持有偏向锁的线程 ID,如果该线程再次访问这个锁的代码块,可以直接访问;
  • epoch:偏向锁在 CAS 锁操作过程中的标识;
  • ptr_to_lock_record:在轻量级锁时,指向栈中锁记录的指针;
  • ptr_to_heavyweight_monitor:在重量级锁时,指向管程 Monitor 的指针。

其中biased_lock和lock参数中不同的标记值,表示的含义如下:

img

lock标记位,通常会在使用到synchronized关键字的对象上发挥作用。随着线程之间竞争激烈程度,对象锁会从无锁状态逐渐升级到重量级锁,其中的变化过程,可以用如下步骤来概括:

  1. 初期锁对象刚创建时,还没有任何线程来竞争,锁状态为 01,偏向锁标识位是0(无线程竞争它),此时程序执行效率最高。
  2. 当有一个线程来竞争锁时,先用偏向锁,表示锁对象偏爱这个线程,这个线程要执行这个锁关联的任何代码,不需要再做任何检查和切换,这种竞争不激烈的情况下,效率也非常高。
  3. 当有两个线程开始竞争这个锁对象时,情况会发生变化,锁会升级为轻量级锁,两个线程公平竞争,哪个线程先占有锁对象并执行代码,锁对象的 Mark Word 就执行哪个线程的栈帧中的锁记录。轻量级锁在加锁过程中,用到了自旋锁。所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁,执行效率有所衰减。
  4. 如果竞争这个锁对象的线程越来越多,会导致更多的切换和等待,JVM 会把该对象的锁升级为重量级锁。这个就是大家常说的同步锁,此时对象中的 Mark Word 会再次发生变化,会指向一个监视器 (Monitor) 对象,这个监视器对象用集合的形式来登记和管理排队的线程。Monitor 依赖操作系统的 MutexLock(互斥锁)来实现线程排队,线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换线程,相比其它级别的锁,此时锁的性能最差。

2.5、 执行方法
方法Java在编译的时候生成的,该方法包含这个类中的实例成员变量声明、实例初始化块和构造方法,作用是给对象执行初始化操作。类中有多少个构造方法就有多少个方法。创建对象时使用哪个构造方法,就执行对应的方法。方法中的语句顺序与实例成员变量初始化顺序一致,下图是实例成员变量的初始化顺序:

当然父类也有方法,初始化对象时,先执行父类的方法再执行子类的方法,如图所示:

img

到这里初始化操作完成之后,Java对象才算真正意义上创建了,这时候才能够使用这个对象。

顺便扩展一下前面的类加载阶段时的静态成员变量初始化。静态成员变量初始化对应的是方法,并且也是JVM自动生成的。方法中的语句顺序与静态成员变量初始化顺序一致,下图是静态成员变量的初始化顺序:

img

注意方法不会在创建对象时执行,只有在类加载的初始化阶段时候,才会执行对应的方法。具体查看Java类的初始化时机。img

最后小实验来打印内存布局情况:

依赖:

1
2
3
4
5
6
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>

代码:

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
27
28
29
30
31
public class ObjectHeaderTest {

public static void main(String[] args) {
System.out.println("=========打印Object对象的大小========");
ClassLayout layout = ClassLayout.parseInstance(new Object());
System.out.println(layout.toPrintable());


System.out.println("========打印数组对象的大小=========");
ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
System.out.println(layout1.toPrintable());


System.out.println("========打印有成员变量的对象大小=========");
ClassLayout layout2 = ClassLayout.parseInstance(new ArtisanTest());
System.out.println(layout2.toPrintable());
}

/**
* ‐XX:+UseCompressedOops 表示开启压缩普通对象指针
* ‐XX:+UseCompressedClassPointers 表示开启压缩类指针
*
*/
public static class ArtisanTest {

int id; //4B
String name; //4B
byte b; //1B
Object o; //4B
}
}

运行结果:

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
27
28
29
30
31
32
33
34
35
=========打印Object对象的大小========
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

========打印数组对象的大小=========
[I object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363)
12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
16 0 int [I.<elements> N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

========打印有成员变量的对象大小=========
com.example.myspringboot001.test.ObjectHeaderTest$ArtisanTest object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 61 e0 00 f8 (01100001 11100000 00000000 11111000) (-134160287)
12 4 int ArtisanTest.id 0
16 1 byte ArtisanTest.b 0
17 3 (alignment/padding gap)
20 4 java.lang.String ArtisanTest.name null
24 4 java.lang.Object ArtisanTest.o null
28 4 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total

7.1 Java的运行过程

「一个Java程序从.Java文件被加载到jvm中,直到被编译成指令码被系统执行的全过程。」

image-20240622223809168

7.1.1 编译

一个java文件要被计算机识别,第一步编译:「要将java源码文件编程成JVM可以解释的class文件。在编译过程中程会对源代码程序做【词法分析】【语法分析】【语义分析】【编译优化】最后生成字节码文件」

【词法分析】

.java文件本质上只是一段字符,要识别这串字符,就需要把字符进行读取处理,将这串字符里面的单词和标点符号等进行识别,「我们在写程序的时候会使用到关键字、标识符、字面量、操作符号等,这些在程序中被称之为token,将一串字符字符串转换为 Token 的过程,就叫做词法分析」

图片

空白字符代表空格、tab、换行符。EOF是结束符

【语法分析】

当将一串字符解析出 Token后,就需要理解它的语法结构,「将其转换成成一个体现语法规则的、树状的数据结构」,这个数据结构叫做「抽象语法树」(AST,Abstract Syntax Tree)。

比如我们定义了一个函数,定义了函数的返回值类型,函数名称,参数以及函数体等,那么它通过语法分析得到的抽象语法树如下:

图片

可以看到这棵 AST 一共四层, 反映了函数的语法结构。分为四层,将函数体里面包含的多个语句,如变量声明语句、返回语句,拆分成函数体的子节点,直到不可拆分的叶子节点。「叶子节点就是词法分析阶段生成的 Token(带方框的部分),当对这棵 AST 做深度优先的遍历,就能依次得到所需要的 Token」

【语义分析】

负责检查抽象语法树的上下文相关属性

  • 变量使用前,需要事先定义
  • 变量运算时,类型需要匹配
  • 变量的作用域问题
  • 还有一些优化等等

【编译优化】

比如「对泛型的擦除」和经常使用的「Lombok的解析」等等。

通过上述四个步骤之后,我们的Java文件就会编译成一个class文件,提供给下一个阶段加载。

image-20240622221116681

7.1.2 加载

类加载相关

1、作用

用于实现类的加载动作、类和加载它的类加载器一起共同确立其在java虚拟机中的唯一性。

2、类加载器的层次

  • 启动类加载器:负责加载存放在/lib目录、被-Xbootclasspath 参数所指定的路径中存放的;并且是Java虚拟机能够识别的类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。

  • 扩展类加载器:在类sun.misc.Launcher$ExtClassLoader中以Java代码形式实现的,负责加载/lib/ext目录中、被java.ext.dirs系统变量所指定的路径中所有的类库,允许用户将具有通用性的类库放置在ext目录里扩展Java SE的功能

  • 应用程序类加载器:在类sun.misc.Launcher$AppClassLoader以Java代码来实现的,用于加载用户类路径下的所有类库。

  • 自定义类加载器

    创建一个新的类并集成ClassLoader类,重写findClass方法;

3、双亲委派模型

图片

  • 自底向上检查类是否已经加载
  • 自顶向下尝试加载类

4、双亲委派模型的好处

  1. 保证类不会重复加载。加载类的过程中,会向上问一下是否加载过,如果已经加载了,则不会再加载,这样可以保证一个类只会被加载一次。
  2. 保证类的安全性。核心的类已经被启动类加载器加载了,后面即使有人篡改了该类,也不会再加载了,防止了一些有危害的代码的植入。

5、双亲委派模型的破坏

  • 兼容JDK1.2之前的代码;重写loadClass方法

  • 自身缺陷;JNDI、JDBC、JCE、JAXB和JBI等等

    线程上下文类加载器,这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果线程创建时还未设置就默认为应用程序类加载器,使用该类加载器去加载所需要的SPI的服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为。java中涉及SPI的加载基本上都采用了这种方式来完成的。

  • 用户对程序动态性的追求;OSGI

  • tomcat容器

    • CommonClassLoader:是Tomcat最基本的类加载器,它加载的类可以被Tomcat容器和Web应用访问。
    • CatalinaClassLoader:是Tomcat容器私有的类加载器,加载类对于Web应用不可见。
    • SharedClassLoader:各个Web应用共享的类加载器,加载的类对于所有Web应用可见,但是对于Tomcat容器不可见。
    • WebAppClassLoader:各个Web应用私有的类加载器,加载类只对当前Web应用可见。比如不同war包应用引入了不同的Spring版本,这样能加载各自的Spring版本,相互隔离。

    图片

加载阶段就是「将编译后的class文件加载到JVM中」。在加载阶段又可以细为【装载】【连接】【初始化】三个步骤。

7.1.2.1 装载

也就是我们常说的类加载,那么是什么是装载呢?

比如说:在程序执行过程中,「JVM在执行某段代码时,需要使用到class A,而此时在内存中没有找到class A的相关信息,于是JVM就会到相应的Class文件中查找class A的类信息,然后将其加载到内存中,这个过程就是类加载过程」

通过上面的过程我们可以总结出类加载过程

  • 「装载时机」

    「JVM不是一开始就把所有的类都加载进内存中」,而是只有第一次需要运行某个类时才会加载,且「只加载一次」

    比如new和反射的时候加载

  • 「装载过程」

    Java虚拟机通过【类加载器】将class文件装载到jvm中的,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机使用的Java类型。

  • 「装载方式」

    在装载过程中为了防止内存中重复加载,使用了双亲委派机制。

    JDK 中的本地方法类一般由启动类加载器(Bootstrp loader)装载

    JDK 中内部实现的扩展类一般由扩展加载器(ExtClassLoader )实现装载,

    程序中的类文件则由系统加载器(AppClassLoader )实现装载。

  • 「总结」

    「加载是一个读取calss文件,将其转化为某种静态数据结构存储在方法区内,并在JVM堆中生成一个便于用户调用的java.lang.Class类型的对象的过程」

    • 字节码来源:本地class文件、jar包class文件、远程网络、动态代理
    • 类加载器:启动类加载器、扩展类加载器、应用类加载器、自定义类加载器
7.1.2.2 连接

主要是「对class的信息进行验证、为「类变量」分配内存空间并对其赋默认值,将类的字节码连接到JVM的运行状态之中」。这个过程一般又被分为【验证】【准备】【解析】。

  • 「验证」

    验证Class类是否符合 Java 规范和 JVM 规范,验证主要包括以下几个方面的验证:

    • 文件格式的验证,验证字节流是否符合Class文件的规范,是否能被当前版本的虚拟机处理

    • 元数据验证,对字节码描述的信息进行语义分析,确保符合java语言规范

    • 字节码验证,通过数据流和控制流分析,确定语义是合法的,符合逻辑的

    • 符号引用验证,这个校验在解析阶段发生,对class静态结构进行语法和语义上的分析,保证其不会产生危害jvm的行为。

    • 「准备」

      「为类的静态变量在方法区分配内存,初始化为【系统】的初始值」

      这里要注意,「在准备阶段的赋值不是我们代码中写的初始值,而是Java虚拟机根据不同变量类型的默认初始值」

      比如:

      1
      2
      public static int a=7
      // 这里在准备阶段过后a的初始值为0,而不是7
    • 「解析」

      「将【常量池】中的符号引用替换为直接引用(内存地址)的过程(如物理内存地址指针),这个阶段有时候会在初始化之后执行」,分为两种情况:

    1. 「将符号引用替换为直接引用」

      有两个类A中引用了B,在编译阶段A是无法知道B有没有被编译的,此时A无法获取B准确的地址,如果A要找到B,就需要在A中「通过一个字符串来代替B的地址,这个字符串就是符号引用」

      当运行的时候A被加载了,到解析的时候发现被A引用的B还没有被加载,此时就会触发B的类加载,「当B被加载到JVM中后,此时A的符号引用就会被替换为B的内存地址,也就是直接引用」

    2. 「java多态,实现后期绑定」

      第一种方式是A引用B的时候,B是一个确定的实现类,是唯一的,这种方式称为静态解析。但是当A调用的B是一个抽象类或接口,它又多个实现的时候,怎么知道引用那个具体的实现呢?

      如果A引用的B是一个拥有多个实现类的抽象类或者接口,这种情况解析阶段并不知道应该引用B的那个实现类,此时就只能先不解析,「等到在运行过程中发生具体的调用的时候,java虚拟机栈得到具体的类信息,此时再进行解析,就可以明确A引用的B用那个实现类的地址了,这也是前面说的解析阶段有时候会在初始化之后执行的原因」

直接引用和符号引用
  • 直接引用:

    直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。

  • 符号引用:

    符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。

7.1.2.3 初始化

「为类的静态变量赋予正确的初始值」

对class的成员变量、静态变量、静态代码块的赋值,如果有【实例化对象】,则会调用方法对实例变量进行初始化,并执行对应的构造方法内的代码。

比如:

1
public static int a=7

在准备阶段,a被赋值了系统默认的初始值0,此时初始化阶段就会将其重新赋值为7。到这里就完成了加载阶段。

image-20240622221612661

「也就是说在初始化过程中,jvm才真正开始执行类中定义的java代码。」

  • 初始化阶段主要是执行类构造器clinit()方法的过程。类构造器clinit()方法是由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的。
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化、则需要先触发其父类的初始化。
  • 虚拟机会保证一个类的clinit()方法在多线程环境中被正确加锁和同步。

image-20240622222342016

7.1.3 解释

「解释阶段:把字节码转换为操作系统识别的指令」

java文件通过「javac」编译成class文件,这种中间码被称为字节码。然后由JVM加载字节码,

初始化完成之后,当我们尝试执行一个类的方法时,会找到对应方法的字节码的信息,然后解释器会把字节码信息解释成一行行系统能识别的指令码来执行。

这个过程「会通过【字节码解释器】【即时编译器(JIT)】相互配合,使java程序几乎能达到和编译型语言一样的执行效率」

  • 「字节码解释器」

    通俗讲:就是将字节码拿过来,翻译成指令码,给系统执行。就跟中英文翻译官一样。

  • 「即时编译器(JIT)」

    通俗讲:其实跟解释器做的事情差不多。也是将字节码拿过来,翻译成指令码,只不过会将指令码保存起来,下次不用翻译了,直接用就行。


在程序运行时,「当JVM发现某个方法或代码块的运行特别频繁的时候,就会把将其认定为【热点代码】,即时编译器(JIT)会将【热点代码】的字节码编译成指令码并保存起来,下次执行的时候就无需重复的进行解释,直接执行缓存的机器语言」

那么问题来了,怎么界定是不是热点代码呢???

在JVM中有个【热点探测】的机制,就是用来检查是否为热点代码的。主要有两种方式:

  • 「基于采样的热点探测」

    「采用采样探测的虚拟机,会周期性的检查每个线程的虚拟机栈栈顶,记录每个方法出现在栈顶的次数,达到阈值后就会认为是热点方法」

    这种方式「简单高效,但是不准确」,因为可能由于线程阻塞而导致某个方法一直在栈顶

  • 「基于计数器的热点探测」

    「采用计数器探测的虚拟机,会为每个方法或者代码块建立一个计数器,统计方法的执行次数。达到阈值后就会认为是热点方法」

    这种方式要维护计数器「,实现相对采用复杂一些,但是结果相对精准」

到这里我们就知道了,上述两种探测方式都有一个阈值,「当到达阈值后就会认为是热点代码,从而触发JIT编译,将编译后的指令码保存起来,下次直接无需解释,直接使用」

7.1.4 执行

「操作系统把解释器解析出来的指令码,调用系统的硬件执行最终的程序指令」

7.2 clinit和init

「在编译生成class文件时,编译器会产生两个方法加于class文件中,一个是类的初始化方法clinit, 另一个是实例的初始化方法init」

7.2.1 类的初始化方法clinit

  • 「如果类中没有静态变量或静态代码块,则clinit方法将不会被生成」
  • 在执行clinit方法时,必须先执行父类的clinit方法。
  • clinit方法只执行一次。
  • static变量的赋值操作和静态代码块的合并顺序由源文件中出现的顺序决定

7.2.2 实例的初始化方法init

「init指的是实例构造器,主要作用是在类实例化过程中执行,执行内容包括非静态成员变量初始化和代码块的执行」

  • 「如果类中没有成员变量和代码块,那么init方法将不会被生成」
  • 在执行init方法时,必须先执行父类的init方法。
  • init方法每实例化一次就会执行一次。
  • init方法先为实例变量分配内存空间,再执行赋默认值,然后根据源码中的顺序执行赋初值或代码块。

本站由 卡卡龙 使用 Stellar 1.27.0 主题创建

本站访问量 次. 本文阅读量 次.