12. 垃圾回收相关概念

[toc]

12. 垃圾回收相关概念

12.1. System.gc()的理解

在默认情况下,通过 system.gc()或者 Runtime.getRuntime().gc() 的调用,会显式触发 Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。

然而 System.gc() 调用附带一个免责声明,无法保证对垃圾收集器的调用。(不能确保立即生效)

JVM 实现者可以通过 System.gc() 调用来决定 JVM 的 GC 行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用 System.gc()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SystemGCTest {
public static void main(String[] args) {
new SystemGCTest();
System.gc();// 提醒JVM的垃圾回收器执行gc,但是不确定是否马上执行gc
// 与Runtime.getRuntime().gc();的作用一样,因为System.gc()底层就是它

System.runFinalization(); // 强制执行使用引用的对象的finalize()方法
}

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("SystemGCTest 重写了finalize()");
}
}

上面的代码例子,

不加上System.runFinalization();,运行时,不是每次都会输出"SystemGCTest 重写了finalize()",证明System.gc()只是提醒JVM的垃圾回收器执行gc,但是不确定是否马上执行gc

加上System.runFinalization();,就会每次都输出"SystemGCTest 重写了finalize()",因为它强制执行使用引用的对象的finalize()方法

12.2. 内存溢出与内存泄露

内存溢出(OOM)

  • 内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。

  • 由于 GC 一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现 ooM 的情况。

  • 大多数情况下,GC 会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的 Full GC 操作,这时候会回收大量的内存,供应用程序继续使用。

  • javadoc 中对 OutOfMemoryError 的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存

首先说没有空闲内存的情况:说明 Java 虚拟机的堆内存不够。原因有二:

  1. Java 虚拟机的堆内存设置不够。

    比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定 JVM 堆大小或者指定数值偏小。我们可以通过参数-Xms-Xmx来调整。

  2. 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)

    对于老版本的 Oracle JDK,因为永久代的大小是有限的,并且 JVM 对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似 intern 字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息,会标记出来和永久代相关:“java.lang.OutOfMemoryError: PermGen space"。

    随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 ooM 有所改观,出现 OOM,异常信息则变成了:“java.lang.OutofMemoryError:Metaspace"。直接内存不足,也会导致 OOM。

这里面隐含着一层意思是,在抛出 OutOfMemoryError 之前,通常垃圾收集器会被触发,尽其所能去清理出空间。

  • 例如:在引用机制分析中,涉及到 JVM 会去尝试回收软引用指向的对象等。
  • java.nio.BIts.reserveMemory()方法中,我们能清楚的看到,System.gc()会被调用,以清理空间。

当然,也不是在任何情况下垃圾收集器都会被触发的

  • 比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM 可以判断出垃圾收集并不能解决这个问题,所以直接抛出 OutOfMemoryError。

内存泄漏(Memory Leak)

也称作“存储渗漏”。严格来说,只有对象不会再被程序用到了,但是 GC 又不能回收他们的情况,才叫内存泄漏

但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致 OOM,也可以叫做宽泛意义上的“内存泄漏”。详说如下:

(一个类里面的方法,把一个变量定义到方法内,作为一个局部变量,那么这个变量出了这个方法它就被回收了。但是你把它定义成成员变量了,生命周期就会长一些,甚至还把这个成员变量定义成了 static ,那么这个静态变量的变量或是类变量就会随着类的加载而加载,随着类的消亡而消亡,这个变量的生命周期就会非常长。如果程序当中出现大量的生命周期很长的对象的时候,加上那些没办法回收的数据的存在,最后导致 OOM 。所以我们把那些生命周期本很长的对象,本来没必要那么长而我们又申明了那么长的对象,也可以理解为宽泛意义的“内存泄露”

尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现 OutOfMemory 异常,导致程序崩溃。

注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。

image-20221123174631209

举例

  1. 单例模式

    单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。

  2. 一些提供 close 的资源未关闭导致内存泄漏

    数据库连接(dataSourse.getConnection() ),网络连接(socket)和 io 连接必须手动 close,否则是不能被回收的。

12.3. Stop The World

Stop-the-World,简称 STW,指的是 GC 事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为 STW。

可达性分析算法中枚举根节点(GC Roots)会导致所有 Java 执行线程停顿。

  • 分析工作必须在一个能确保一致性的快照中进行
  • 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
  • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证

被 STW 中断的应用程序线程会在完成 GC 之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少 STW 的发生。

STW 事件和采用哪款 GC 无关,所有的 GC 都有这个事件。

哪怕是 G1 也不能完全避免 Stop-the-World 情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。

STW 是 JVM 在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。

开发中不要用 System.gc(),否则 会导致 Stop-the-World 的发生。

12.4. 垃圾回收的并行与并发

并发(Concurrent)

在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。

并发不是真正意义上的“同时进行”,只是 CPU 把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于 CPU 处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。

image-20200712202522051

并行(Parallel)

当系统有一个以上 CPU 时,当一个 CPU 执行一个进程时,另一个 CPU 可以执行另一个进程,两个进程互不抢占 CPU 资源,可以同时进行,我们称之为并行(Parallel)。

其实决定并行的因素不是 CPU 的数量,而是 CPU 的核心数量,比如一个 CPU 多个核也可以并行。

适合科学计算,后台处理等弱交互场景

image-20200712202822129

并发 vs 并行

  • 并发,指的是多个事情,在同一时间段内同时发生了。

  • 并行,指的是多个事情,在同一时间点上同时发生了。

  • 并发的多个任务之间是互相抢占资源的。

  • 并行的多个任务之间是不互相抢占资源的。

  • 只有在多 CPU 或者一个 CPU 多核的情况中,才会发生并行。

  • 否则,看似同时发生的事情,其实都是并发执行的。

垃圾回收的并发与并行

并发和并行,在谈论垃圾收集器的上下文语境中,它们可以解释如下:

并行(Parallel)

多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。如 ParNew、Parallel Scavenge、Parallel Old;

串行(Serial)

相较于并行的概念,单线程执行。如果内存不够,则程序暂停,启动 JM 垃圾回收器进行垃圾回收。回收完,再启动程序的线程。

image-20210512112822896

并发(Concurrent)

用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。用户程序在继续运行,而垃圾收集程序线程运行于另一个 CPU 上;如:CMS、G1

image-20200712203815517

12.5. 安全点与安全区域

安全点

程序执行时并非在所有地方都能停顿下来开始 GC,只有在特定的位置才能停顿下来开始 GC,这些位置称为“安全点(Safepoint)”。

Safe Point 的选择很重要,如果太少可能导致 GC 等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为 Safe Point,如方法调用、循环跳转和异常跳转等

如何在 GC 发生时,检查所有线程都跑到最近的安全点停顿下来呢?

抢先式中断:(目前没有虚拟机采用了)

  • 首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。\

主动式中断

设置一个中断标志,各个线程运行到 Safe Point 的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。(有轮询的机制)

安全区域(Safe Resion)

Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safepoint。但是,程序“不执行”的时候呢?例如线程处于 Sleep 状态或 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走”到安全点去中断挂起,JVM 也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始 Gc 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safepoint。

实际执行时:

  1. 当线程运行到 Safe Region 的代码时,首先标识已经进入了 Safe Relgion,如果这段时间内发生 GC,JVM 会忽略标识为 Safe Region 状态的线程
  2. 当线程即将离开 Safe Region 时,会检查 JVM 是否已经完成 GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开 Safe Region 的信号为止;

12.6. 再谈引用:强引用

我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。

【既偏门又非常高频的面试题】强引用、软引用、弱引用、虚引用有什么区别?具体使用场景是什么?

在 JDK1.2 版之后,Java 对引用的概念进行了扩充,将引用分为:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)这 4 种引用强度依次逐渐减弱。

除强引用外,其他 3 种引用均可以在 java.lang.ref 包中找到它们的身影。如下图,显示了这 3 种引用类型对应的类,开发人员可以在应用程序中直接使用它们。

.image-20200712205813321

Reference 子类中只有终结器引用是包内可见的,其他 3 种引用类型均为 public,可以在应用程序中直接使用

  • 强引用(StrongReference):最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用(SoftReference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存流出异常。
  • 弱引用(WeakReference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。
  • 虚引用(PhantomReference):一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

强引用(Strong Reference)——不回收

在 Java 程序中,最常见的引用类型是强引用(普通系统 99%以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型

当在 Java 语言中使用 new 操作符创建一个新的对象,并将其赋值给一个变量的时候,这个变量就成为指向该对象的一个强引用。

强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象。

对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 nu11,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略。

相对的,软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成 Java 内存泄漏的主要原因之一

强引用例子

1
StringBuffer str = new StringBuffer("hello mogublog");

局部变量 str 指向 StringBuffer 实例所在堆空间,通过 str 可以操作该实例,那么 str 就是 StringBuffer 实例的强引用

对应内存结构

image-20200712211501377

此时,如果再运行一个赋值语句

1
StringBuffer str1 = str;

对应的内存结构

image-20200712211732976

本例中的两个引用,都是强引用,强引用具备以下特点:

  • 强引用可以直接访问目标对象。
  • 强引用所指向的对象在任何时候都不会被系统回收,虚拟机宁愿抛出 OOM 异常,也不会回收强引用所指向对象。
  • 强引用可能导致内存泄漏。

12.8. 再谈引用: 软引用

软引用(Soft Reference)——内存不足即回收

(内存足够 --> 不会回收软引用的可达对象; 内存不够 --> 会回收软引用的可达对象)

软引用是用来描述一些还有用,但非必需的对象(必须对象由强引用描述)。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收(一次回收是不可达对象),如果这次回收还没有足够的内存,才会抛出内存溢出异常。

软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列(Reference Queue)。

类似弱引用,只不过 Java 虚拟机会尽量让软引用的存活时间长一些,迫不得已才清理。

在 JDK1.2 版之后提供了 java.lang.ref.SoftReference 类来实现软引用

1
2
3
Object obj = new Object(); // 声明强引用
SoftReference<Object> sf = new SoftReference<>(obj);
obj = null; //销毁强引用

image-20221124152020225

12.9. 再谈引用:弱引用

弱引用(Weak Reference)——发现即回收

弱引用也是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统 GC 时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。

但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间

弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况。

软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。

在 JDK1.2 版之后提供了 WeakReference 类来实现弱引用

1
2
3
Object obj = new Object(); // 声明强引用
WeakReference<Object> sf = new WeakReference<>(obj);
obj = null; //销毁强引用

弱引用对象与软引用对象的最大不同就在于,当 GC 在进行回收时,需要通过算法检查是否回收软引用对象,而对于弱引用对象,GC 总是进行回收。弱引用对象更容易、更快被 GC 回收

image-20221124153725910

面试题:你开发中使用过 WeakHashMap 吗?

WeakHashMap 用来存储图片信息,可以在内存不足的时候,及时回收,避免了 OOM

12.X. 再谈引用:虚引用

虚引用(Phantom Reference)——对象回收跟踪

也称为“幽灵引用”或者“幻影引用”,是所有引用类型中最弱的一个。

一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。

它不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的 get()方法取得对象时,总是 null

为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。

虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。

由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录。

在 JDK1.2 版之后提供了 PhantomReference 类来实现虚引用。

1
2
3
4
Object obj = new Object(); // 声明强引用
ReferenceQueue phantomQueue = new ReferenceQueue();
PhantomReference<Object> sf = new PhantomReference<>(obj, phantomQueue);
obj = null;

12.11. 终结器引用

它用于实现对象的 finalize() 方法,也可以称为终结器引用。无需手动编码,其内部配合引用队列使用。

在 GC 时,终结器引用入队。由 Finalizer 线程通过终结器引用找到被引用对象调用它的 finalize()方法,第二次 GC 时才回收被引用的对象

适配器模式

5.2 适配器模式

5.2.1 概述

如果去欧洲国家去旅游的话,他们的插座如下图最左边,是欧洲标准。而我们使用的插头如下图最右边的。因此我们的笔记本电脑,手机在当地不能直接充电。所以就需要一个插座转换器,转换器第1面插入当地的插座,第2面供我们充电,这样使得我们的插头在当地能使用。生活中这样的例子很多,手机充电器(将220v转换为5v的电压),读卡器等,其实就是使用到了适配器模式。

img

定义:

将一个类的接口转换成客户希望的另外一个接口,使得原本由于接口不兼容而不能一起工作的那些类能一起工作。

适配器模式分为类适配器模式和对象适配器模式,前者类之间的耦合度比后者高,且要求程序员了解现有组件库中的相关组件的内部结构,所以应用相对较少些。

5.2.2 结构

适配器模式(Adapter)包含以下主要角色:

  • 目标(Target)接口:当前系统业务所期待的接口,它可以是抽象类或接口。
  • 适配者(Adaptee)类:它是被访问和适配的现存组件库中的组件接口。
  • 适配器(Adapter)类:它是一个转换器,通过继承或引用适配者的对象,把适配者接口转换成目标接口,让客户按目标接口的格式访问适配者。

5.2.3 类适配器模式

实现方式:定义一个适配器类来实现当前系统的业务接口,同时又继承现有组件库中已经存在的组件。

【例】读卡器

现有一台电脑只能读取SD卡,而要读取TF卡中的内容的话就需要使用到适配器模式。创建一个读卡器,将TF卡中的内容读取出来。

类图如下:

image-20221121221506653

代码如下:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
//SD卡的接口
public interface SDCard {
//读取SD卡方法
String readSD();
//写入SD卡功能
void writeSD(String msg);
}

//SD卡实现类
public class SDCardImpl implements SDCard {
public String readSD() {
String msg = "sd card read a msg :hello word SD";
return msg;
}

public void writeSD(String msg) {
System.out.println("sd card write msg : " + msg);
}
}

//电脑类
public class Computer {

public String readSD(SDCard sdCard) {
if(sdCard == null) {
throw new NullPointerException("sd card null");
}
return sdCard.readSD();
}
}

//TF卡接口
public interface TFCard {
//读取TF卡方法
String readTF();
//写入TF卡功能
void writeTF(String msg);
}

//TF卡实现类
public class TFCardImpl implements TFCard {

public String readTF() {
String msg ="tf card read msg : hello word tf card";
return msg;
}

public void writeTF(String msg) {
System.out.println("tf card write a msg : " + msg);
}
}

//定义适配器类(SD兼容TF)
public class SDAdapterTF extends TFCardImpl implements SDCard {

public String readSD() {
System.out.println("adapter read tf card ");
return readTF();
}

public void writeSD(String msg) {
System.out.println("adapter write tf card");
writeTF(msg);
}
}

//测试类
public class Client {
public static void main(String[] args) {
Computer computer = new Computer();
SDCard sdCard = new SDCardImpl();
System.out.println(computer.readSD(sdCard));

System.out.println("------------");

SDAdapterTF adapter = new SDAdapterTF();
System.out.println(computer.readSD(adapter));
}
}Copy to clipboardErrorCopied

类适配器模式违背了合成复用原则。类适配器是客户类有一个接口规范的情况下可用,反之不可用。

5.2.4 对象适配器模式

实现方式:对象适配器模式可釆用将现有组件库中已经实现的组件引入适配器类中,该类同时实现当前系统的业务接口。

【例】读卡器

我们使用对象适配器模式将读卡器的案例进行改写。类图如下:

img

代码如下:

类适配器模式的代码,我们只需要修改适配器类(SDAdapterTF)和测试类。

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
//创建适配器对象(SD兼容TF)
public class SDAdapterTF implements SDCard {

private TFCard tfCard;

public SDAdapterTF(TFCard tfCard) {
this.tfCard = tfCard;
}

public String readSD() {
System.out.println("adapter read tf card ");
return tfCard.readTF();
}

public void writeSD(String msg) {
System.out.println("adapter write tf card");
tfCard.writeTF(msg);
}
}

//测试类
public class Client {
public static void main(String[] args) {
Computer computer = new Computer();
SDCard sdCard = new SDCardImpl();
System.out.println(computer.readSD(sdCard));

System.out.println("------------");

TFCard tfCard = new TFCardImpl();
SDAdapterTF adapter = new SDAdapterTF(tfCard);
System.out.println(computer.readSD(adapter));
}
}Copy to clipboardErrorCopied

注意:还有一个适配器模式是接口适配器模式。当不希望实现一个接口中所有的方法时,可以创建一个抽象类Adapter ,实现所有方法。而此时我们只需要继承该抽象类即可。

5.2.5 应用场景

  • 以前开发的系统存在满足新系统功能需求的类,但其接口同新系统的接口不一致。
  • 使用第三方提供的组件,但组件接口定义和自己要求的接口定义不同。

5.2.6 JDK源码解析

Reader(字符流)、InputStream(字节流)的适配使用的是InputStreamReader。

InputStreamReader继承自java.io包中的Reader,对他中的抽象的未实现的方法给出实现。如:

1
2
3
4
5
6
7
public int read() throws IOException {
return sd.read();
}

public int read(char cbuf[], int offset, int length) throws IOException {
return sd.read(cbuf, offset, length);
}Copy to clipboardErrorCopied

如上代码中的sd(StreamDecoder类对象),在Sun的JDK实现中,实际的方法实现是对sun.nio.cs.StreamDecoder类的同名方法的调用封装。类结构图如下:

img

从上图可以看出:

  • InputStreamReader是对同样实现了Reader的StreamDecoder的封装。
  • StreamDecoder不是Java SE API中的内容,是Sun JDK给出的自身实现。但我们知道他们对构造方法中的字节流类(InputStream)进行封装,并通过该类进行了字节流和字符流之间的解码转换。

结论:

从表层来看,InputStreamReader做了InputStream字节流类到Reader字符流之间的转换。而从如上Sun JDK中的实现类关系结构中可以看出,是StreamDecoder的设计实现在实际上采用了适配器模式。

11. 垃圾回收概述及算法

[toc]

11. 垃圾回收概述及算法

11.1. 垃圾回收概述

11.1.1. 什么是垃圾?

image-20200712085456113

垃圾收集,不是 Java 语言的伴生产物。早在 1960 年,第一门开始使用内存动态分配和垃圾收集技术的 Lisp 语言诞生。

关于垃圾收集有三个经典问题:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

垃圾收集机制是 Java 的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java 的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战,这当然也是面试的热点。

大厂面试题

CMS的全称是Mostly Concurrent Mark and Sweep Garbage Collector(主要并发标记清除垃圾回收器),它在年轻代使用复制算法,在老年代使用标记-清除算法,在老年代比起Mark-Sweep,多了一个并发设计。

蚂蚁金服

  • 你知道哪几种垃圾回收器,各自的优缺点,重点讲一下 cms 和 G1?

  • JVM GC 算法有哪些,目前的 JDK 版本采用什么回收算法?

  • G1 回收器讲下回收过程 GC 是什么?为什么要有 GC?

  • GC 的两种判定方法?CMS 收集器与 G1 收集器的特点

百度

  • 说一下 GC 算法,分代回收说下

  • 垃圾收集策略和算法

天猫

  • JVM GC 原理,JVM 怎么回收内存

  • CMS 特点,垃圾回收算法有哪些?各自的优缺点,他们共同的缺点是什么?

滴滴

  • Java 的垃圾回收器都有哪些,说下 g1 的应用场景,平时你是如何搭配使用垃圾回收器的

京东

  • 你知道哪几种垃圾收集器,各自的优缺点,重点讲下 cms 和 G1,

  • 包括原理,流程,优缺点。垃圾回收算法的实现原理

阿里

  • 讲一讲垃圾回收算法。

  • 什么情况下触发垃圾回收?

  • 如何选择合适的垃圾收集算法?

  • JVM 有哪三种垃圾回收器?

字节跳动

  • 常见的垃圾回收器算法有哪些,各有什么优劣?
  • System.gc()和 Runtime.gc()会做什么事情?
  • Java GC 机制?GC Roots 有哪些?
  • Java 对象的回收方式,回收算法。
  • CMS 和 G1 了解么,CMS 解决什么问题,说一下回收的过程。
  • CMS 回收停顿了几次,为什么要停顿两次?

什么是垃圾?

An object is considered garbage when it can no longer be reached from any pointer in the running program

垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾 。

如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序的结束,被保留的空间无法被其它对象使用,甚至可能导致内存溢出。

磁盘碎片整理的日子

机械硬盘需要进行磁盘整理,同时还有坏道

image-20200712090848669

11.1.2. 为什么需要 GC

想要学习 GC,首先需要理解为什么需要 GC?

对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。

除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM 将整理出的内存分配给新的对象

随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有 GC 就不能保证应用程序的正常进行。而经常造成 STW 的 GC 又跟不上实际的需求,所以才会不断地尝试对 GC 进行优化。

11.1.3. 早期垃圾回收

在早期的 C/C++时代,垃圾回收基本上是手工进行的。开发人员可以使用 new 关键字进行内存申请,并使用 delete 关键字进行内存释放。比如以下代码:

1
2
3
4
MibBridge *pBridge= new cmBaseGroupBridge();
//如果注册失败,使用Delete释放该对象所占内存区域
if (pBridge->Register(kDestroy) != NO ERROR)
delete pBridge;

这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担。倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃

在有了垃圾回收机制后,上述代码极有可能变成这样

1
2
MibBridge *pBridge = new cmBaseGroupBridge();
pBridge->Register(kDestroy);

现在,除了 Java 以外,C#、Python、Ruby 等语言都使用了自动垃圾回收的思想,也是未来发展趋势,可以说这种自动化的内存分配和来及回收方式已经成为了现代开发语言必备的标准。

11.1.4. Java 垃圾回收机制

自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险

  • 没有垃圾回收器,java 也会和 cpp 一样,各种悬垂指针,野指针,泄露问题让你头疼不已。

自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发

oracle 官网关于垃圾回收的介绍 https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/toc.html

担忧

对于 Java 开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化 Java 开发人员在程序出现内存溢出时定位问题和解决问题的能力

此时,了解 JVM 的自动内存分配和内存回收原理就显得非常重要,只有在真正了解 JVM 是如何管理内存后,我们才能够在遇见 outofMemoryError 时,快速地根据错误异常日志定位问题和解决问题。

当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节

GC 主要关注的区域

GC 主要关注于 方法区 和堆中的垃圾收集

image-20200712092427246

垃圾收集器可以对年轻代回收,也可以对老年代回收,甚至是全栈和方法区的回收。其中,Java 堆是垃圾收集器的工作重点

从次数上讲:

  • 频繁收集 Young 区
  • 较少收集 Old 区
  • 基本不收集 Perm 区(元空间)

11.2. 垃圾回收相关算法(面试的概率比较高)

对象存活判断

在堆里存放着几乎所有的 Java 对象实例,在 GC 执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象只有被标记为己经死亡的对象,GC 才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段

那么在 JVM 中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。

判断对象存活一般有两种方式:引用计数算法可达性分析算法

11.2.1. 标记阶段:引用计数算法

方式一:引用计数算法

引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。

对于一个对象 A,只要有任何一个对象引用了 A,则 A 的引用计数器就加 1;当引用失效时,引用计数器就减 1。只要对象 A 的引用计数器的值为 0,即表示对象 A 不可能再被使用,可进行回收。

优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。

缺点:

  • 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销
  • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销
  • 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在 Java 的垃圾回收器中没有使用这类算法。

循环引用

当 p 的指针断开的时候,内部的引用形成一个循环,这就是循环引用

image-20200712102205795

举例

测试 Java 中是否采用的是引用计数算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class RefCountGC {
// 这个成员属性的唯一作用就是占用一点内存
private byte[] bigSize = new byte[5*1024*1024];
// 引用
Object reference = null;

public static void main(String[] args) {
RefCountGC obj1 = new RefCountGC();
RefCountGC obj2 = new RefCountGC();

obj1.reference = obj2;
obj2.reference = obj1;

obj1 = null;
obj2 = null;
// 显示的执行垃圾收集行为
// 这里发生GC,obj1和obj2是否被回收?
System.gc();
}
}
// 运行结果
PSYoungGen: 15490K->808K(76288K)] 15490K->816K(251392K)

上述进行了 GC 收集的行为,所以可以证明 JVM 中采用的不是引用计数器的算法

image-20200712103230349

小结

引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的 Python,它更是同时支持引用计数和垃圾收集机制。

具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。

Java 并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系

Python 如何解决循环引用?

  • 手动解除:很好理解,就是在合适的时机,解除引用关系。 使用弱引用 weakref,weakref 是 Python 提供的标准库,旨在解决循环引用。

11.2.2. 标记阶段:可达性分析算法

方式二:可达性分析算法(根搜索算法、追踪性垃圾收集)

相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生

相较于引用计数算法,这里的可达性分析就是 Java、C#选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection)

所谓"GCRoots”根集合就是一组必须活跃的引用。

基本思路

  • 可达性分析算法是以根对象集合(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达
  • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
  • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
  • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。

image-20210511195540451

在 Java 语言中,GC Roots 包括以下几类元素:

  • 虚拟机栈中引用的对象
    • 比如:各个线程被调用的方法中使用到的参数、局部变量等。
  • 本地方法栈内 JNI(通常说的本地方法)引用的对象
  • 方法区中类静态属性引用的对象
    • 比如:Java 类的引用类型静态变量
  • 方法区中常量引用的对象
    • 比如:字符串常量池(String Table)里的引用
  • 所有被同步锁 synchronized 持有的对象
  • Java 虚拟机内部的引用。
    • 基本数据类型对应的 Class 对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器。
  • 反映 java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

image-20200712104622677

( 栈,方法区,常量池,如果有引用堆空间里面的实体的话,比如里面那些蓝色的,就都是可达的对象,就不是垃圾。红色的那些就不可达的,就是垃圾 )

  • 除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整 GC Roots 集合。比如:分代收集和局部回收(PartialGC)。

    • 如果只针对 Java 堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入 GCRoots 集合中去考虑,才能保证可达性分析的准确性。
    • 比如: 非新生代的,比如说老年代,也会考虑作为GC Roots的一部分,因为单独看新生代的话,也有可能会被老年代的一些引用所指向了
  • 小技巧: 由于 Root 采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个 Root。

注意

如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行(比如,一个对象刚被引用完和表示可达的,就被标记上是垃圾了,这就不是一致性了,那肯定不行不符合的)。这点不满足的话分析结果的准确性就无法保证。

这点也是导致 GC 进行时必须“stop The World”的一个重要原因。

  • 即使是号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。

11.2.3. 对象的 finalization 机制

Java 语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑(就是对象被销毁前加一些逻辑代码进行处理)。

当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的 finalize()方法。

image-20221122223605544

finalize() 方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。

finalize() 方法本来是空的,没有重写finalize方法的话,调用了也没有什么意义,但即便是重写了,也不建议主动去调用,如下说明:

永远不要主动调用某个对象的 finalize()方法 , 应该交给垃圾回收机制调用。理由包括下面三点:

  • 在 finalize()时可能会导致对象复活。
  • finalize()方法的执行时间是没有保障的(你调用它,它也不能保证马上执行),它完全由 GC 线程决定,极端情况下,若不发生 GC,则 finalize()方法将没有执行机会。
  • 一个糟糕的 finalize()会严重影响 GC的性能。(比如,自己重写时写了个死循环在里面或是执行速度很慢,调用,那么GC的性能就会验证受影响)
  • 它本来是空的,没有重写finalize方法的话,调用了也没有什么意义

从功能上来说,finalize()方法与 C中的析构函数比较相似,但是 Java 采用的是基于垃圾回收器的自动内存管理机制,所以 finalize()方法在本质上不同于 C中的析构函数。

由于 finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态(可触及的,可复活的,不可触及的)。

生存还是死亡?

如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:

  • 可触及的:从根节点开始,可以到达这个对象。
  • 可复活的:对象的所有引用都被释放,但是对象有可能在 finalize()中复活。
  • 不可触及的:对象的 finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次

以上 3 种状态中,是由于 inalize()方法的存在,进行的区分。只有在对象不可触及时才可以被回收。

具体过程

判定一个对象 objA 是否可回收,至少要经历两次标记过程:

  1. 如果对象 objA 到 GC Roots 没有引用链,则进行第一次标记
  2. 进行筛选,判断此对象是否有必要执行 finalize()方法
  3. 如果对象 objA 没有重写 finalize()方法,或者 finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA 被判定为不可触及的。
  4. 如果对象 objA 重写了 finalize()方法,且还未执行过,那么 objA 会被插入到 F-Queue 队列中,由一个虚拟机自动创建的、低优先级的 Finalizer 线程触发其 finalize()方法执行。
  5. finalize()方法是对象逃脱死亡的最后机会,稍后 GC 会对 F-Queue 队列中的对象进行第二次标记。如果 objA 在 finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA 会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize 方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的 finalize 方法只会被调用一次

举例

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
36
37
public class CanReliveObj {
// 类变量,属于GC Roots的一部分
public static CanReliveObj canReliveObj;

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("调用当前类重写的finalize()方法");
canReliveObj = this; // 当前待回收的对象在 finalize() 方法中与引用链上的一个对象canReliveObj建立联系
}

public static void main(String[] args) throws InterruptedException {
canReliveObj = new CanReliveObj();
canReliveObj = null;
System.gc();
System.out.println("-----------------第一次gc操作------------");
// 因为Finalizer线程的优先级比较低,暂停2秒,以等待它
Thread.sleep(2000);
if (canReliveObj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}

System.out.println("-----------------第二次gc操作------------");
canReliveObj = null;
System.gc();
// 下面代码和上面代码是一样的,但是 canReliveObj却自救失败了
Thread.sleep(2000);
if (canReliveObj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}

}
}

运行结果

1
2
3
4
5
-----------------第一次gc操作------------
调用当前类重写的finalize()方法
obj is still alive
-----------------第二次gc操作------------
obj is dead

在第一次 GC 时,执行了 finalize 方法,但 finalize()方法只会被调用一次,所以第二次该对象被 GC 标记并清除了。

11.2.4. MAT 与 JProfiler 的 GC Roots 溯源

MAT 是什么?

MAT 是 Memory Analyzer 的简称,它是一款功能强大的 Java 堆内存分析器。用于查找内存泄漏以及查看内存消耗情况。

MAT 是基于 Eclipse 开发的,是一款免费的性能分析工具。

大家可以在 http://www.eclipse.org/mat/ 下载并使用 MAT

获取 dump 文件

方式一:命令行使用 jmap

image-20210512105418987

方式二:使用 JVisualVM 导出

捕获的 heap dump 文件是一个临时文件,关闭 JVisualVM 后自动删除,若要保留,需要将其另存为文件。

可通过以下方法捕获 heap dump:

  • 在左侧“Application"(应用程序)子窗口中右击相应的应用程序,选择 Heap Dump(堆 Dump)。

  • 在 Monitor(监视)子标签页中点击 Heap Dump(堆 Dump)按钮。

本地应用程序的 Heap dumps 作为应用程序标签页的一个子标签页打开。同时,heap dump 在左侧的 Application(应用程序)栏中对应一个含有时间戳的节点。

右击这个节点选择 save as(另存为)即可将 heap dump 保存到本地。

方式三:使用 MAT 打开 Dump 文件

image-20221123091417318

JProfiler 的 GC Roots 溯源

(本身这个对象我已经不想用它了,它已经不再会被使用了,它就是垃圾,但是当我们用Class分析算法时,会发现,它直接或间接地还被GC Roots关联着,导致还没办法将它GC,这就是java真正的内存泄露。那出现内存泄漏, 我们可以溯源,发现它确实和GC Roots连上了,那可以在适当的位置给它断开。所以我们在开发中,一般都会看某个对象上的GC Roots是什么,这就是GC Roots溯源

我们在实际的开发中,一般不会查找全部的 GC Roots,可能只是查找某个对象的整个链路,或者称为 GC Roots 溯源,这个时候,我们就可以使用 JProfiler

image-20221123093811032

view --> Mark Current Values

image-20221123093839170

image-20221123094354023

可以将当前对象基于当前节点后的个数的变化的值展现出来,便可以看到有哪些对象变化比较大,变化比较大的就是我们要关注的一些对象了。

对象个数比较大或是说占用空间比较大,当我们GC(点击Run GC)的时候,我们还始终回收不了它们

image-20221123094522918

我们发现char数组对象比较多,我们可以鼠标指着–>点右键–>Show Selection In Heap Walker–>ok

image-20221123094721985

分配,大对象,相关引用。我们更关注的是引用,引用用得比较多

image-20221123095009723

点击References之后,就可以看到所有的参数。想知道有哪些有内存泄漏问题,就可以做GC Roots溯源

image-20221123095352781

image-20221123092829645

Outgoing references:定位代码的大概位置

Incoming references:判断和哪个GC Roots是相关联的

image-20221123093245549

11.2.5. 清除阶段:标记-清除算法

当成功区分出内存中存活对象和死亡对象后,GC 接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。

目前在 JVM 中比较常见的三种垃圾收集算法标记一清除算法(Mark-Sweep)、复制算法(copying)、标记-压缩算法(Mark-Compact)

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

标记-清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法,该算法被 J.McCarthy 等人在 1960 年提出并并应用于 Lisp 语言。

执行过程

当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为 stop the world),然后进行两项工作,第一项则是标记,第二项则是清除

  • 标记:Collector 从引用根节点开始遍历,标记所有被引用的对象(即GC Roots可到达的对象)。一般是在对象的 Header 中记录为可达对象。

  • 清除:Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收

image-20200712150935078

缺点

  • 标记清除算法的效率不算高(全部对象都要遍历)
  • 在进行 GC 的时候,需要停止整个应用程序,用户体验较差
  • 这种方式清理出来的空闲内存是不连续的,产生内碎片,需要维护一个空闲列表

何为清除?

这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放,覆盖原有的地址

11.2.6. 清除阶段:复制算法

复制(Copying)算法

为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky 于 1963 年发表了著名的论文,“使用双存储区的 Lisp 语言垃圾收集器 CA LISP Garbage Collector Algorithm Using Serial Secondary Storage)”。M.L.Minsky 在该论文中描述的算法被人们称为复制(Copying)算法,它也被 M.L.Minsky 本人成功地引入到了 Lisp 语言的一个实现版本中。

核心思想

将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收

image-20200712151916991

优点

  • 没有标记和清除过程,实现简单,运行高效
  • 复制过去以后保证空间的连续性,不会出现“碎片”问题。

缺点

  • 此算法的缺点也是很明显的,就是需要两倍的内存空间。
  • 对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销也不小

特别的

如果系统中的垃圾对象很多,复制算法不会很理想(复制太多的话,最后发现回收的不多,而且还要维护 region 之间对象引用关系还很多)。因为复制算法需要复制的存活对象数量并不会太大,或者说非常低才行

应用场景

新生代,对常规应用的垃圾回收,一次通常可以回收 70% - 99% 的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。

image-20200712152847218

11.2.7. 清除阶段:标记-压缩(整理)算法

标记-压缩(或标记-整理、Mark-Compact)算法

复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。

标记一清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以 JVM 的设计者需要在此基础之上进行改进。标记-压缩(Mark-Compact)算法由此诞生。

1970 年前后,G.L.Steele、C.J.Chene 和 D.s.Wise 等研究者发布标记-压缩算法。在许多现代的垃圾收集器中,人们都使用了标记-压缩算法或其改进版本。

执行过程

  1. 第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象

  2. 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。

  3. 之后,清理边界外所有的空间。

image-20200712153236508

标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法

二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

指针碰撞(Bump the Pointer)

如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump tHe Pointer)。

优点

  • 消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可。
  • 消除了复制算法当中,内存减半的高额代价。

缺点

  • 从效率上来说,标记-整理算法要低于复制算法。
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
  • 移动过程中,需要全程暂停用户应用程序。即:STW

11.2.8. 小结

对比三种算法

Mark-Sweep Mark-Compact Copying
速率 中等 最慢 最快
空间开销 少(但会堆积碎片) 少(不堆积碎片) 通常需要活对象的 2 倍空间(不堆积碎片)
移动对象

效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。

而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段

难道就没有一种最优算法吗?

回答:无,没有最好的算法,只有最合适的算法。

image-20210512104415281

11.2.9. 分代收集算法

前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。

分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。

在 Java 程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http 请求中的 Session 对象、线程、Socket 连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String 对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

目前几乎所有的 GC 都采用分代手机算法执行垃圾回收的。

在 HotSpot 中,基于分代的概念,GC 所使用的内存回收算法必须结合年轻代和老年代各自的特点。

年轻代(Young Gen)

年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。

这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过 hotspot 中的两个 survivor 的设计得到缓解。

老年代(Tenured Gen)

老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁

这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现。

  • Mark 阶段的开销与存活对象的数量成正比。
  • Sweep 阶段的开销与所管理区域的大小成正相关。
  • Compact 阶段的开销与存活对象的数据成正比。

以 HotSpot 中的 CMS 回收器为例,CMS 是基于 Mark-Sweep 实现的,对于对象的回收效率很高。而对于碎片问题,CMS 采用基于 Mark-Compact 算法的 Serial Old 回收器作为补偿措施:当内存回收不佳(碎片导致的 Concurrent Mode Failure 时),将采用 Serial Old 执行 Full GC 以达到对老年代内存的整理。

分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代

11.2.X. 增量收集算法、分区算法

增量收集算法

上述现有的算法,在垃圾回收过程中,应用软件将处于一种 Stop the World 的状态。在 Stop the World 状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting)算法的诞生。

基本思想

如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成

总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作

缺点

使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降

分区算法

一般来说,在相同条件下,堆空间越大,一次 Gc 时所需要的时间就越长,有关 GC 产生的停顿也越长。为了更好地控制 GC 产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次 GC 所产生的停顿。

分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。

每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

image-20200712165318590

写到最后

注意,这些只是基本的算法思路,实际 GC 实现过程要复杂的多,目前还在发展中的前沿 GC 都是复合算法,并且并行和并发兼备。

10. StringTable

[toc]

10. StringTable

10.1. String 的基本特性

  • String:字符串,使用一对""引起来表示
  • String 声明为 final 的,不可被继承
  • String 实现了 Serializable 接口:表示字符串是支持序列化的。
  • String 实现了 Comparable 接口:表示 string 可以比较大小
  • String 在 jdk8 及以前内部定义了 final char[] value 用于存储字符串数据。JDK9 时改为 byte[]

10.1.1. String 在 jdk9 中存储结构变更

官网地址:JEP 254: Compact Strings (java.net)

Motivation

The current implementation of the String class stores characters in a char array, using two bytes (sixteen bits) for each character. Data gathered from many different applications indicates that strings are a major component of heap usage and, moreover, that most String objects contain only Latin-1 characters. Such characters require only one byte of storage, hence half of the space in the internal char arrays of such String objects is going unused.

Description

We propose to change the internal representation of the String class from a UTF-16 char array to a byte array plus an encoding-flag field. The new String class will store characters encoded either as ISO-8859-1/Latin-1 (one byte per character), or as UTF-16 (two bytes per character), based upon the contents of the string. The encoding flag will indicate which encoding is used.

String-related classes such as AbstractStringBuilder, StringBuilder, and StringBuffer will be updated to use the same representation, as will the HotSpot VM’s intrinsic string operations.

This is purely an implementation change, with no changes to existing public interfaces. There are no plans to add any new public APIs or other interfaces.

The prototyping work done to date confirms the expected reduction in memory footprint, substantial reductions of GC activity, and minor performance regressions in some corner cases.

动机

目前 String 类的实现将字符存储在一个 char 数组中,每个字符使用两个字节(16 位)。从许多不同的应用中收集到的数据表明,字符串是堆使用的主要组成部分,此外,大多数字符串对象只包含 Latin-1 字符。这些字符只需要一个字节的存储空间,因此这些字符串对象的内部字符数组中有一半的空间没有被使用。

说明

我们建议将 String 类的内部表示方法从 UTF-16 字符数组改为字节数组加编码标志域。新的 String 类将根据字符串的内容,以 ISO-8859-1/Latin-1(每个字符一个字节)或 UTF-16(每个字符两个字节)的方式存储字符编码。编码标志将表明使用的是哪种编码。


与字符串相关的类,如AbstractStringBuilder、StringBuilder 和 StringBuffer 将被更新以使用相同的表示方法,HotSpot VM 的内在字符串操作也是如此

这纯粹是一个实现上的变化,对现有的公共接口没有变化。目前没有计划增加任何新的公共 API 或其他接口。

迄今为止所做的原型设计工作证实了内存占用的预期减少,GC 活动的大幅减少,以及在某些角落情况下的轻微性能倒退。

结论:String 再也不用 char[] 来存储了,改成了 byte [] 加上编码标记,节约了一些空间

1
2
3
4
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
@Stable
private final byte[] value;
}

10.1.2. String 的基本特性

String:代表不可变的字符序列。简称:不可变性。

  • 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的 value 进行赋值。
  • 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的 value 进行赋值。
  • 当调用 string 的 **replace()**方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的 value 进行赋值。

通过字面量的方式(区别于 new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。

字符串常量池是不会存储相同内容的字符串的

String 的 String Pool 是一个固定大小的 Hashtable,默认值大小长度是 1009。如果放进 String Pool 的 String 非常多,就会造成 Hash 冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用 String.intern 时性能会大幅下降

如:通过设置不同长度来测试性能:image-20221121105838424

使用-XX:StringTablesize可设置 StringTable 的长度

  • 在 jdk6 中 StringTable 是固定的,就是 1009 的长度,所以如果常量池中的字符串过多就会导致效率下降很快。StringTablesize 设置没有要求
  • 在 jdk7 中,StringTable 的长度默认值是 60013,StringTablesize 设置没有要求
  • 在 JDK8 中,设置 StringTable 长度的话,1009 是可以设置的最小值
    • 如果设置小于1009的话,就会报如下错误:image-20221121105440727

例子:(String 的基本使用,体现String的不可变性)

image-20221121102353645

image-20221121100748082

image-20221121102438865

无论你是char数组还是byte数组,字符串一旦定义好了,其实字符串底层的数组长度就定死了,要想往后面拼接的话,数组扩容本身也不能往后补,就得重新造一个。其二的话,堆空间中的字符串常量池,只要你修改,就得去重新创建,这就体现了不可变性

image-20221121102458111

面试题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 一道String的面试题
*/
public class StringExer {
String str = new String("good");
char[] ch = {'t', 'e', 's', 't'};

public void change(String str, char ch[]) {
str = "test ok";
ch[0] = 'b';
}

public static void main(String[] args) {
StringExer ex = new StringExer();
ex.change(ex.str, ex.ch);
System.out.println(ex.str); // good
System.out.println(ex.ch); // best
}
}

(5条消息) java 中String到底是值传递还是引用传递的问题_技术无产者的博客-CSDN博客

10.2. String 的内存分配

在 Java 语言中有 8 种基本数据类型和一种比较特殊的类型 String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。

常量池就类似一个 Java 系统级别提供的缓存。8 种基本数据类型的常量池都是系统协调的,String 类型的常量池比较特殊。它的主要使用方法有两种。

  • 直接使用双引号声明出来的 String 对象会直接存储在常量池中。

  • 如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern()方法。这个后面重点谈

Java 6 及以前,字符串常量池存放在永久代

如:字符串常量池装不下时报oom显示永久代PermGen空间:image-20221121111144881

Java 7 中 Oracle 的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到 Java 堆内

  • 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
  • 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在 Java 7 中使用String.intern()

如:字符串常量池装不下时报oom显示堆空间(和jdk8一样):image-20221121111122217

Java8 元空间,字符串常量在

image-20200711093546398

image-20200711093558709

StringTable 为什么要调整?

官网地址:Java SE 7 Features and Enhancements (oracle.com)

Synopsis: In JDK 7, interned strings are no longer allocated in the permanent generation of the Java heap, but are instead allocated in the main part of the Java heap (known as the young and old generations), along with the other objects created by the application. This change will result in more data residing in the main Java heap, and less data in the permanent generation, and thus may require heap sizes to be adjusted. Most applications will see only relatively small differences in heap usage due to this change, but larger applications that load many classes or make heavy use of the String.intern() method will see more significant differences.

简介:在 JDK 7 中,内部字符串不再分配在 Java 堆的永久代中,而是分配在 Java 堆的主要部分(称为年轻代和老年代),与应用程序创建的其他对象一起。这种变化将导致更多的数据驻留在主 Java 堆中,而更少的数据在永久代中,因此可能需要调整堆的大小。大多数应用程序将看到由于这一变化而导致的堆使用的相对较小的差异,但加载许多类或大量使用 String.intern()方法的大型应用程序将看到更明显的差异

10.3. String 的基本操作

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
@Test
public void test1() {
System.out.println(); //2320
System.out.println("1"); //2321
System.out.println("2");
System.out.println("3");
System.out.println("4");
System.out.println("5");
System.out.println("6");
System.out.println("7");
System.out.println("8");
System.out.println("9");
System.out.println("10"); //2330

// 下面的字符串就不会再次加载进字符串常量池了,因为上面都已经加载过了,常量池汇中都有了
System.out.println("1"); //2321
System.out.println("2"); //2322
System.out.println("3");
System.out.println("4");
System.out.println(l"5");
System.out.println("6");
System.out.println("7");
System.out.println("8");
System.out.println("9");
System.out.println("10");//2330
}

debug模式查看:

image-20221121113447184

Java 语言规范里要求完全相同的字符串字面量,应该包含同样的 Unicode 字符序列(包含同一份码点序列的常量),并且必须是指向同一个 String 类实例。

1
2
3
4
5
6
7
8
9
10
11
12
class Memory {
public static void main(String[] args) {//line 1
int i= 1;//line 2
Object obj = new Object();//line 3
Memory mem = new Memory();//Line 4
mem.foo(obj);//Line 5
}//Line 9
private void foo(Object param) {//line 6
String str = param.toString();//line 7
System.out.println(str);
}//Line 8
}

image-20210511111607132

10.4. 字符串拼接操作

  • 常量与常量的拼接结果在常量池,原理是编译期优化
  • 常量池中不会存在相同内容的变量
  • 只要其中有一个是变量,结果就在中。变量拼接的原理是 StringBuilder
  • 如果拼接的结果调用 intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址

举例 1

1
2
3
4
5
6
7
8
9
public static void test1() {
// 都是常量,前端编译期会进行代码优化
// 通过idea直接看对应的反编译的class文件,会显示 String s1 = "abc"; 说明做了代码优化
String s1 = "a" + "b" + "c";
String s2 = "abc";

// true,有上述可知,s1和s2实际上指向字符串常量池中的同一个值
System.out.println(s1 == s2); // true
}

举例 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
public void test2() {
String s1 = "javaEE";
String s2 = "hadoop";

String s3 = "javaEEhadoop";
String s4 = "javaEE" + "hadoop";
String s5 = s1 + "hadoop";
String s6 = "javaEE" + s2;
String s7 = s1 + s2;

System.out.println(s3 == s4); // true 编译期优化
System.out.println(s3 == s5); // false s1是变量,不能编译期优化
System.out.println(s3 == s6); // false s2是变量,不能编译期优化
System.out.println(s3 == s7); // false s1、s2都是变量
System.out.println(s5 == s6); // false s5、s6 不同的对象实例
System.out.println(s5 == s7); // false s5、s7 不同的对象实例
System.out.println(s6 == s7); // false s6、s7 不同的对象实例

String s8 = s6.intern();
System.out.println(s3 == s8); // true intern之后,s8和s3一样,指向字符串常量池中的"javaEEhadoop"
}

image-20221121151128082

虽然 s3 和 s4 的指向的值都是“ab”,但是他们
各自存放的地方不一样,也就是地址不一样

举例 3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
1. 字符串拼接操作不一定使用的是 StringBuilder (变量拼接的原理是 StringBuilder)
如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译优化,即非StringBuilder的方式
2. 针对于 final 修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上 final 的时候建议使用上
*/
public void test6(){
String s0 = "beijing";
String s1 = "bei";
String s2 = "jing";
String s3 = s1 + s2;
System.out.println(s0 == s3); // false s3指向堆空降中的对象实例,s0指向字符串常量池中的"beijing"

String s7 = "shanxi";
final String s4 = "shan";
final String s5 = "xi";
String s6 = s4 + s5;
System.out.println(s6 == s7); // true s4和s5是final修饰的,编译期就能确定s6的值了
}
  • 不使用 final 修饰,即为变量。如 s3 行的 s1 和 s2,会通过 new StringBuilder 进行拼接
  • 使用 final 修饰,即为常量。会在编译器进行代码优化。在实际开发中,能够使用 final 的,尽量使用

举例 4

1
2
3
4
5
6
7
public void test3(){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3==s4);
}

字节码

我们拿例 4 的字节码进行查看,可以发现s1 + s2实际上是 new 了一个 StringBuilder 对象,并使用了 append 方法将 s1 和 s2 添加进来,最后调用了 toString 方法赋给 s4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 0 ldc #2 <a>
2 astore_1
3 ldc #3 <b>
5 astore_2
6 ldc #4 <ab>
8 astore_3
9 new #5 <java/lang/StringBuilder>
12 dup
13 invokespecial #6 <java/lang/StringBuilder.<init>>
16 aload_1
17 invokevirtual #7 <java/lang/StringBuilder.append>
20 aload_2
21 invokevirtual #7 <java/lang/StringBuilder.append>
24 invokevirtual #8 <java/lang/StringBuilder.toString>
27 astore 4
29 getstatic #9 <java/lang/System.out>
32 aload_3
33 aload 4
35 if_acmpne 42 (+7)
38 iconst_1
39 goto 43 (+4)
42 iconst_0
43 invokevirtual #10 <java/io/PrintStream.println>
46 return

字符串拼接操作性能对比

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
36
37
38
39
public class Test{   
public static void main(String[] args) {
int times = 50000;
// String
long start = System.currentTimeMillis();
testString(times);
long end = System.currentTimeMillis();
System.out.println("String: " + (end-start) + "ms");
// StringBuilder
start = System.currentTimeMillis();
testStringBuilder(times);
end = System.currentTimeMillis();
System.out.println("StringBuilder: " + (end-start) + "ms");
// StringBuffer
start = System.currentTimeMillis();
testStringBuffer(times);
end = System.currentTimeMillis();
System.out.println("StringBuffer: " + (end-start) + "ms");
}
public static void testString(int times) {
String str = "";
for (int i = 0; i < times; i++) {
str = str + "test";
}
}
public static void testStringBuilder(int times) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < times; i++) {
sb.append("test");
}
}
public static void testStringBuffer(int times) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < times; i++) {
sb.append("test");
}
}
}
// 结果String: 7963ms StringBuilder: 1ms StringBuffer: 4ms
  1. 本实验进行 5 万次循环,String 拼接方式的时间是 StringBuilder.append 方式的约 8000 倍,StringBuffer.append()方式的时间是 StringBuilder.append()方式的约 4 倍(StringBuffer是同步的,会慢些)

  2. 可以看到,通过 StringBuilder 的 append 方式的速度,要比直接对 String 使用“+”拼接的方式快的不是一点半点

​ 因为:(1)StringBuiler 的 append()的方式:自始自终只创建过一个StringBuilder的对象

​ 使用String的字符串拼接方式:创建过多个 StringBuilder 和 String 的对象

​ (2)使用String 的字符串拼接方式:内存中由于创建了较多的StringBuilder和String的对象,内存占用更大;如果进行GC,需要花费的额外时间。

  1. 那么,在实际开发中,对于需要多次或大量拼接的操作,在不考虑线程安全问题时,我们就应该尽可能使用 StringBuilder 进行 append 操作

  2. 改进的空间:除此之外,还有那些操作能够帮助我们提高字符串方面的运行效率呢?(改进的空间)

​ 在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值hightLevel的情况下,建议使用带参构造器指定 capacity来实例化,以减少扩容的次数,优化程序执行效果(扩容的逻辑可以自行查看源代码)

​ StringBuilder 空参构造器的初始化大小为 16。

​ StringBuilder s = new StringBuilder(capacity); // new char[capacity]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* Constructs a string builder with no characters in it and an * initial capacity of 16 characters.
*/
public StringBuilder() {
super(16);
}
/**
* Constructs a string builder with no characters in it and an
* initial capacity specified by the {@code capacity} argument.
*
* @param capacity the initial capacity.
* @throws NegativeArraySizeException if the {@code capacity}
* argument is less than {@code 0}.
*/public StringBuilder(int capacity) {
super(capacity);
}

10.5. intern()的使用

官方 API 文档中的解释

public String intern()

Returns a canonical representation for the string object.

A pool of strings, initially empty, is maintained privately by the class String.

When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned. Otherwise, this String object is added to the pool and a reference to this String object is returned.

It follows that for any two strings s and t, s.intern() == t.intern() is true if and only if s.equals(t) is true.

All literal strings and string-valued constant expressions are interned. String literals are defined in section 3.10.5 of the The Java™ Language Specification.

  • Returns:

    a string that has the same contents as this string, but is guaranteed to be from a pool of unique strings.

当调用 intern 方法时,如果池子里已经包含了一个与这个 String 对象相等的字符串,正如 equals(Object)方法所确定的,那么池子里的字符串会被返回。否则,这个 String 对象被添加到池中,并返回这个 String 对象的引用。

由此可见,对于任何两个字符串 s 和 t,当且仅当 s.equals(t)为真时,s.intern() == t.intern()为真。

所有字面字符串和以字符串为值的常量表达式都是 interned。

返回一个与此字符串内容相同的字符串,但保证是来自一个唯一的字符串池。


intern 是一个 native 方法,调用的是底层 C 的方法

1
public native String intern();

如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern 方法,它会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。

1
String myInfo = new string("I love atguigu").intern();

也就是说,如果在任意字符串上调用 String.intern 方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是 true

1
("a"+"b"+"c").intern() == "abc"

通俗点讲,Interned string 就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)

image-20221121160359225

如何保证变量 s 指向的是字符串常量池中的数据呢?

有两种方式:

方式一:字面量定义的方式

​ String s = “hhhh”;

方式二:调用 intern()

​ String s = new String(“hhhh”).intern();

​ String s = new StringBuilder(“hhhh”).toString().intern();

(用字面量或者intern方法才会在字符串常量池中创建,否则在堆中创建)

image-20221121161348231

image-20221121163558377

10.5.1. intern 的使用:JDK6 vs JDK7/8

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
/** 
* ① String s = new String("1")
* 创建了两个对象
* 堆空间中一个new对象
* 字符串常量池中一个字符串常量"1"(注意:此时字符串常量池中已有"1")
* ② s.intern(); 调用此方法之前,字符串常量池中已存在了"1"
*
* s 指向的是堆空间中的对象地址
* s2 指向的是堆空间中常量池中"1"的地址
* 所以不相等
*/
String s = new String("1");
s.intern();
String s2 = "1";
System.out.println(s==s2);// jdk1.6 false jdk7/8 false

/* *
① String s3 = new String("1") + new String("1")
* s3变量记录的地址为new String("11"),等价于new String("11"),但是,常量池中并不生成字符串"11";

* ② s3.intern()
* 由于此时常量池中并无"11",所以这里会在字符串常量池中生成"11",如何理解:
jdk6: 创建了一个新的对象"11",也就有新的地址
jdk7: 此时常量中并没有创建"11",则会把 对象的引用地址 复制一份,放入串池,并返回串池中的引用地址。
即,创建一个指向堆空间中 new String("11") 的地址
* String s4 = "11"; // s4 变量记录的地址:使用的是上一行代码执行时,在常量池中生成的"11"的地址
* 所以s3 和 s4 指向的都是一个地址
*/
String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3==s4); //jdk1.6 false jdk7/8 true

image-20210511152240683

image-20200711145925091

面试题的扩展:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class StringIntern1 {
public static void main(String[] args) {
// StringIntern.java中的练习扩展

String s3 = new String("1") + new String("1");
// 执行完上一行代码以后,字符串常量池中,不存在 "11"

String s4 = "11"; //在字符串常量池中生成对象"11"

s3.intern();
System.out.println( s3 == s4 ); // false

String s5 = s3.intern();
System.out.println( s5 == s4 ); // true

s3 = s3.intern();
System.out.println( s3 == s4 ); // true
}
}

总结 String 的 intern()的使用:

JDK1.6 中,将这个字符串对象尝试放入串池。

  • 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
  • 如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址

JDK1.7 起,将这个字符串对象尝试放入串池。

  • 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
  • 如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址

练习 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class StringExer1 {
public static void main(String[] args) {
String s = new String("a") + new String("b"); // 变量s记录的地址为new String("ab")
// 在执行完上一行代码后,字符串常量池中并没有"ab"

/**
* jdk6中:在 字符串常量池 中 创建一个 字符串"ab"
* jdk8中:字符串常量池 中 没有创建 字符串"ab",而是 创建 了一个 引用(指向 new String ("ab"))
*/
String s2 = s.intern();

System.out.println(s2 == "ab"); // jdk6: true jdk8: true
System.out.println(s == "ab"); // jdk6: false jdk8: true
}
}

图解:

image-20200711150859709

image-20200711151326909

练习 2

image-20200711151433277

练习3:(自己设置的)

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
String s = "ab";
String s1 = new String("ab");
System.out.println(s1 == "ab"); // false
System.out.println(s1 == s); // false

String s2 = "ab";
System.out.println(s2 == "ab"); // true
System.out.println(s2 == s); // true
}

10.5.2. intern 的效率测试:空间角度

我们通过测试一下,使用了 intern 和不使用的时候,其实相差还挺多的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class StringIntern2 { 
static final int MAX_COUNT = 1000 * 10000;
static final String[] arr = new String[MAX_COUNT];
public static void main(String[] args) {
Integer [] data = new Integer[]{1,2,3,4,5,6,7,8,9,10};
long start = System.currentTimeMillis();
for (int i = 0; i < MAX_COUNT; i++) {
// arr[i] = new String(String.valueOf(data[i%data.length]));
arr[i] = new String(String.valueOf(data[i%data.length])).intern();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start));
try {
Thread.sleep(1000000);
} catch (Exception e) {
e.getStackTrace();
}
}
}
// 运行结果: 不使用intern:7256ms 使用intern:1395ms

结论

对于程序中大量使用存在的字符串时,尤其存在很多已经重复的字符串时,使用 intern()方法能够节省内存空间。

而 arr[i] = new String(String.valueOf(data[i%data.length])).intern(); 中前面的 new String(String.valueOf(data[i%data.length])) 的对象,在堆空间中,后期被发现不用就会被GC垃圾回收销毁了,所以就不会在内存中创建过多的String

大的网站平台,需要内存中存储大量的字符串。比如社交网站,很多人都存储:北京市、海淀区等信息。这时候如果字符串都调用 intern()方法,就会很明显降低内存的大小。

10.6. StringTable 的垃圾回收

1
2
3
4
5
6
7
8
9
10
public class StringGCTest {  
/**
* -Xms15m -Xmx15m -XX:+PrintGCDetails
*/
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
String.valueOf(i).intern();
}
}
}

运行结果

1
2
3
[GC (Allocation Failure) [PSYoungGen: 4096K->504K(4608K)] 4096K->1689K(15872K), 0.0581583 secs] [Times: user=0.00 sys=0.00, real=0.06 secs] [GC (Allocation Failure) [PSYoungGen: 4600K->504K(4608K)] 5785K->2310K(15872K), 0.0015621 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [GC (Allocation Failure) [PSYoungGen: 4600K->504K(4608K)] 6406K->2350K(15872K), 0.0034849 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap PSYoungGen
total 4608K, used 1919K [0x00000000ffb00000, 0x0000000100000000, 0x0000000100000000) eden space 4096K, 34% used [0x00000000ffb00000,0x00000000ffc61d30,0x00000000fff00000) from space 512K, 98% used [0x00000000fff00000,0x00000000fff7e010,0x00000000fff80000) to space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000) ParOldGen total 11264K, used 1846K [0x00000000ff000000, 0x00000000ffb00000, 0x00000000ffb00000) object space 11264K, 16% used [0x00000000ff000000,0x00000000ff1cd9b0,0x00000000ffb00000) Metaspace used 3378K, capacity 4496K, committed 4864K, reserved 1056768K class space used 361K, capacity 388K, committed 512K, reserved 1048576K

10.7. G1 中的 String 去重操作

官网地址:JEP 192: String Deduplication in G1 (java.net)

Motivation

Many large-scale Java applications are currently bottlenecked on memory. Measurements have shown that roughly 25% of the Java heap live data set in these types of applications is consumed by String objects. Further, roughly half of those String objects are duplicates, where duplicates means string1.equals(string2) is true. Having duplicate String objects on the heap is, essentially, just a waste of memory. This project will implement automatic and continuous String deduplication in the G1 garbage collector to avoid wasting memory and reduce the memory footprint.

目前,许多大规模的 Java 应用程序在内存上遇到了瓶颈。测量表明,在这些类型的应用程序中,大约 25%的 Java 堆实时数据集被String'对象所消耗。此外,这些 "String "对象中大约有一半是重复的,其中重复意味着 "string1.equals(string2) "是真的。在堆上有重复的String’对象,从本质上讲,只是一种内存的浪费。这个项目将在 G1 垃圾收集器中实现自动和持续的`String’重复数据删除,以避免浪费内存,减少内存占用。


注意这里说的重复,指的是在堆中的数据,而不是常量池中的,因为常量池中的本身就不会重复

背景:对许多 Java 应用(有大的也有小的)做的测试得出以下结果:

  • 堆存活数据集合里面 string 对象占了 25%
  • 堆存活数据集合里面重复的 string 对象有 13.5%
  • string 对象的平均长度是 45

许多大规模的 Java 应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java 堆中存活的数据集合差不多 25%是 String 对象。更进一步,这里面差不多一半 string 对象是重复的,重复的意思是说: stringl.equals(string2)= true堆上存在重复的 String 对象必然是一种内存的浪费。这个项目将在 G1 垃圾收集器中实现自动持续对重复的 string 对象进行去重,这样就能避免浪费内存。

实现

  1. 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的 String 对象
  2. 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的 string 对象。
  3. 使用一个 hashtable 来记录所有的被 String 对象使用的不重复的 char 数组。当去重的时候,会查这个 hashtable,来看堆上是否已经存在一个一模一样的 char 数组。
  4. 如果存在,String 对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
  5. 如果查找失败,char 数组会被插入到 hashtable,这样以后的时候就可以共享这个数组了。

命令行选项

1
2
3
4
5
6
7
8
# 开启String去重,默认是不开启的,需要手动开启。 
UseStringDeduplication(bool)

# 打印详细的去重统计信息
PrintStringDeduplicationStatistics(bool)

# 达到这个年龄的String对象被认为是去重的候选对象
StringpeDuplicationAgeThreshold(uintx)

2. 类加载子系统

[toc]

2. 类加载子系统

2.1. 内存结构概述

  • Class 文件
  • 类加载子系统
  • 运行时数据区
    • 方法区
    • 程序计数器
    • 虚拟机栈
    • 本地方法栈
  • 执行引擎
  • 本地方法接口
  • 本地方法库

image-20200705080719531

image-20200705080911284

如果自己想手写一个 Java 虚拟机的话,主要考虑哪些结构呢?

  • 类加载器
  • 执行引擎

2.2. 类加载器与类的加载过程

类加载器子系统作用

image-20200705081813409

  • 类加载器子系统负责从文件系统或者网络中加载 Class 文件,class 文件在文件开头有特定的文件标识。
  • ClassLoader 只负责 class 文件的加载,至于它是否可以运行,则由 Execution Engine 执行引擎 决定。
  • 加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是 Class 文件中常量池部分的内存映射)

类加载器 ClassLoader 角色

image-20200705081913538

  • class file 存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到 JVM 当中来根据这个文件实例化出 n 个一模一样的实例。
  • class file 加载到 JVM 中,被称为 DNA 元数据模板,放在方法区。
  • 在.class 文件->JVM->最终成为元数据模板,此过程就要一个运输工具(类装载器 Class Loader),扮演一个快递员的角色。

类的加载过程

1
2
3
4
5
6
7
8
/**
*示例代码
*/
public class HelloLoader {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}

用流程图表示上述示例代码:

image-20200705082255746

加载阶段

image-20200705082601441

    1. 通过一个类的全限定名获取定义此类的二进制字节流
    1. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
    1. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口

补充:加载 class 文件的方式

  • 本地系统中直接加载
  • 通过网络获取,典型场景:Web Applet
  • 从 zip压缩包中读取,成为日后 jar、war 格式的基础
  • 运行时计算生成,使用最多的是:动态代理技术
  • 由其他文件生成,典型场景:JSP 应用
  • 从专有数据库中提取.class 文件,比较少见
  • 加密文件中获取,典型的防 Class 文件被反编译的保护措施

链接阶段

  • 验证(Verify)
    • 目的在于确保 Class 文件的字节流中包含信息符合当前虚拟机要求保证被加载类的正确性,不会危害虚拟机自身安全。
    • 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
  • 准备(Prepare)
    • 为类变量分配内存并且设置该类变量的默认初始值,即零值。
    • 这里不包含用 final 修饰的 static,因为 final 在编译的时候就会分配了,准备阶段会 显式初始化
    • 这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到 Java 堆中。
  • 解析(Resolve)
    • 常量池内符号引用转换为直接引用的过程。
    • 事实上,解析操作往往会伴随着 JVM 在执行完初始化之后再执行。
    • 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java 虚拟机规范》的 Class 文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
    • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的 CONSTANT_Class_info,CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等。

初始化阶段

  • 初始化阶段就是执行类构造器方法<clinit>()的过程。
  • 此方法不需定义,是 javac 编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
  • 构造器方法中指令按语句在源文件中出现的顺序执行。
  • <clinit>()不同于类的构造器。(关联:构造器是虚拟机视角下的<init>())
  • 若该类具有父类,JVM 会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕。
  • 虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁

2.3. 类加载器分类

JVM 支持两种类型的类加载器 。分别为引导类加载器(Bootstrap ClassLoader)自定义类加载器(User-Defined ClassLoader)

从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是 Java 虚拟机规范却没有这么定义,而是将所有派生于抽象类 ClassLoader 的类加载器都划分为自定义类加载器

无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有 3 个,如下所示:

image-20200705094149223

这里的四者之间的关系是包含关系。不是上层下层,也不是子父类的继承关系。

2.3.1. 虚拟机自带的加载器

启动类加载器(引导类加载器,Bootstrap ClassLoader)

  • 这个类加载使用 C/C++语言实现的,嵌套在 JVM 内部。
  • 它用来加载 Java 的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar 或 sun.boot.class.path 路径下的内容),用于提供 JVM 自身需要的类
  • 并不继承自 java.lang.ClassLoader,没有父加载器。
  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
  • 出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类

扩展类加载器(Extension ClassLoader)

  • Java 语言编写,由 sun.misc.Launcher$ExtClassLoader 实现。
  • 派生于 ClassLoader 类
  • 父类加载器为启动类加载器
  • 从 java.ext.dirs 系统属性所指定的目录中加载类库,或从 JDK 的安装目录的 jre/1ib/ext 子目录(扩展目录)下加载类库。如果用户创建的 JAR 放在此目录下,也会自动由扩展类加载器加载。

应用程序类加载器(系统类加载器,AppClassLoader)

  • java 语言编写,由 sun.misc.LaunchersAppClassLoader 实现
  • 派生于 ClassLoader 类
  • 父类加载器为扩展类加载器
  • 它负责加载环境变量 classpath 或系统属性 java.class.path 指定路径下的类库
  • 该类加载是程序中默认的类加载器,一般来说,Java 应用的类都是由它来完成加载
  • 通过 ClassLoader#getSystemclassLoader() 方法可以获取到该类加载器

2.3.2. 用户自定义类加载器

在 Java 的日常应用程序开发中,类的加载几乎是由上述 3 种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。

为什么要自定义类加载器?

  • 隔离加载类
  • 修改类加载的方式
  • 扩展加载源
  • 防止源码泄漏

用户自定义类加载器实现步骤:

  1. 开发人员可以通过继承抽象类 java.lang.ClassLoader 类的方式,实现自己的类加载器,以满足一些特殊的需求
  2. 在 JDK1.2 之前,在自定义类加载器时,总会去继承 ClassLoader 类并重写 loadClass() 方法,从而实现自定义的类加载类,但是在 JDK1.2 之后已不再建议用户去覆盖 loadclass() 方法,而是建议把自定义的类加载逻辑写在 findClass()方法中
  3. 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承 URLClassLoader 类,这样就可以避免自己去编写 findClass() 方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。

2.4. ClassLoader 的使用说明

ClassLoader 类是一个抽象类,其后所有的类加载器都继承自 ClassLoader(不包括启动类加载器)

image-20200705103516138

sun.misc.Launcher 它是一个 java 虚拟机的入口应用

image-20200705103636003

获取 ClassLoader 的途径

  • 方式一:获取当前 ClassLoader

    1
    clazz.getClassLoader()
  • 方式二:获取当前线程上下文的 ClassLoader

    1
    Thread.currentThread().getContextClassLoader()
  • 方式三:获取系统的 ClassLoader

    1
    ClassLoader.getSystemClassLoader()
  • 方式四:获取调用者的 ClassLoader

    1
    DriverManager.getCallerClassLoader()

2.5. 双亲委派机制

Java 虚拟机对 class 文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的 class 文件加载到内存生成 class 对象。而且加载某个类的 class 文件时,Java 虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。

工作原理

  • 1)如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
  • 2)如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
  • 3)如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

image-20200705105151258

举例

当我们加载 jdbc.jar 用于实现数据库连接的时候,首先我们需要知道的是 jdbc.jar 是基于 SPI 接口进行实现的,所以在加载的时候,会进行双亲委派,最终从根加载器中加载 SPI 核心类,然后在加载 SPI 接口类,接着在进行反向委派,通过线程上下文类加载器进行实现类 jdbc.jar 的加载。

image-20200705105810107

优势

  • 避免类的重复加载
  • 保护程序安全,防止核心 API 被随意篡改
    • 自定义类:java.lang.String
    • 自定义类:java.lang.ShkStart(报错:阻止创建 java.lang 开头的类)

沙箱安全机制

自定义 String 类,但是在加载自定义 String 类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载 jdk 自带的文件(rt.jar 包中 java\lang\String.class),报错信息说没有 main 方法,就是因为加载的是 rt.jar 包中的 string 类。这样可以保证对 java 核心源代码的保护,这就是沙箱安全机制。

2.6. 其他

如何判断两个 class 对象是否相同

在 JVM 中表示两个 class 对象是否为同一个类存在两个必要条件:

  • 类的完整类名必须一致,包括包名。
  • 加载这个类的 ClassLoader(指 ClassLoader 实例对象)必须相同。

换句话说,在 JVM 中,即使这两个类对象(class 对象)来源同一个 Class 文件,被同一个虚拟机所加载,但只要加载它们的 ClassLoader 实例对象不同,那么这两个类对象也是不相等的。

对类加载器的引用

JVM 必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么 JVM 会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM 需要保证这两个类型的类加载器是相同的。

类的主动使用和被动使用

Java 程序对类的使用方式分为:主动使用和被动使用。

主动使用,又分为七种情况:

  • 创建类的实例

  • 访问某个类或接口的静态变量,或者对该静态变量赋值

  • 调用类的静态方法

  • 反射(比如:Class.forName(“com.atguigu.Test”))

  • 初始化一个类的子类

  • Java 虚拟机启动时被标明为启动类的类

  • JDK 7 开始提供的动态语言支持:

    java.lang.invoke.MethodHandle 实例的解析结果

    REF_getStatic、REF_putStatic、REF_invokeStatic 句柄对应的类没有初始化,则初始化

除了以上七种情况,其他使用 Java 类的方式都被看作是对类的被动使用,都不会导致类的初始化


image-20221120230332074

image-20221120230322953

image-20221120230310840

image-20221120230259551

image-20221120230251515

image-20221120230241584

image-20221120230234436

image-20221120230223130

image-20221120230214513

image-20221120230150104

9. 执行引擎

[toc]

9. 执行引擎

9.1. 执行引擎概述

执行引擎属于 JVM 的下层,里面包括解释器、及时编译器、垃圾回收器

image-20200710080707873

执行引擎是 Java 虚拟机核心的组成部分之一。

“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式

JVM 的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被 JVM 所识别的字节码指令、符号表,以及其他辅助信息。

image-20200710081118053

那么,如果想要让一个 Java 程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令.才可以。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-H3dqdi5T-1620741818957)(https://gitee.com/vectorx/ImageCloud/raw/master/img/20210511090655.png)]

9.1.1. 执行引擎的工作流程

  1. 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于 PC 寄存器。
  2. 每当执行完一项指令操作后,PC 寄存器就会更新下一条需要被执行的指令地址。
  3. 当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在 Java 堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。

image-20200710081627217

从外观上来看,所有的 Java 虚拟机的执行引擎输入,输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行过程

image-20221120193453610

执行引擎就是负责将字节码指令,翻译成对应的机器指令,让程序能在操作系统中去执行,

9.2. Java 代码编译和执行过程

image-20200710082141643

(绿色代表解析过程,蓝色代表编译过程)

大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤

Java 代码编译是由 Java 源码编译器(前端编译器)来完成,流程图如下所示:

image-20200710082433146

Java 字节码的执行是由 JVM 执行引擎(后端编译器)来完成,流程图 如下所示

image-20200710083036258

9.2.1. 什么是解释器(Interpreter)?什么是 JIT 编译器?

解释器:当 Java 虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。

JIT(Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。

9.2.2. 为什么 Java 是半编译半解释型语言?

JDK1.0 时代,将 Java 语言定位为“解释执行”还是比较准确的。再后来,Java 也发展出可以直接生成本地代码的编译器。现在 JVM 在执行 Java 代码的时候,通常都会将解释执行与编译执行二者结合起来进行。

图示

image-20200710083656277

9.3. 机器码、指令、汇编语言

9.3.1. 机器码

各种用二进制编码方式表示的指令,叫做机器指令码。开始,人们就用它采编写程序,这就是机器语言。

机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。

用它编写的程序一经输入计算机,CPU 直接读取运行,因此和其他语言编的程序相比,执行速度最快。

机器指令与 CPU 紧密相关,所以不同种类的 CPU 所对应的机器指令也就不同。

9.3.2. 指令

由于机器码是有 0 和 1 组成的二进制序列,可读性实在太差,于是人们发明了指令。

指令就是把机器码中特定的 0 和 1 序列,简化成对应的指令(一般为英文简写,如 mov,inc 等),可读性稍好

由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如 mov),对应的机器码也可能不同。

9.3.3. 指令集

不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。 如常见的

  • x86 指令集,对应的是 x86 架构的平台
  • ARM 指令集,对应的是 ARM 架构的平台

9.3.4. 汇编语言

由于指令的可读性还是太差,于是人们又发明了汇编语言。

在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用<mark 地址符号(Symbol)或标号(Label)代替指令或操作数的地址。在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。

由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。

9.3.5. 高级语言

为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言

当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序或编译程序。

image-20200710085323733

高级语言也不是直接翻译成机器指令,而是翻译成汇编语言码,如下面说的 C 和 C++

C、C++源程序执行过程

编译过程又可以分成两个阶段:编译和汇编。

编译过程:是读取源程序(字符流),对之进行词法和语法的分析,将高级语言指令转换为功能等效的汇编代码

汇编过程:实际上指把汇编语言代码翻译成目标机器指令的过程。

image-20200710085553258

9.3.6. 字节码

(字节码主要是为了实现跨平台性)

字节码是一种中间状态(中间码)的二进制代码(文件),它比机器码更抽象,需要直译器转译后才能成为机器码

字节码主要为了实现特定软件运行和软件环境、与硬件环境无关

字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。字节码典型的应用为:Java bytecode

image-20210511092336091

9.4. 解释器

JVM 设计者们的初衷仅仅只是单纯地为了满足 Java 程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。

image-20200710090203674

为什么 Java 源文件不直接翻译成 JMV,而是翻译成字节码文件?可能是因为直接翻译的代价是比较大的

9.4.1. 解释器工作机制

解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。

当一条字节码指令被解释执行完成后,接着再根据 PC 寄存器中记录的下一条需要被执行的字节码指令执行解释操作。

9.4.2. 解释器分类

在 Java 的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。

  • 字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。
  • 模板解释器每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。

在 HotSpot VM 中,解释器主要由 Interpreter 模块和 Code 模块构成。

  • Interpreter 模块:实现了解释器的核心功能
  • Code 模块:用于管理 HotSpot VM 在运行时生成的本地机器指令

9.4.3. 现状

由于解释器在设计和实现上非常简单,因此除了 Java 语言之外,还有许多高级语言同样也是基于解释器执行的,比如 Python、Perl、Ruby 等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些 C/C++程序员所调侃。

为了解决这个问题,JVM 平台支持一种叫作即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。

不过无论如何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献。

9.5. JIT 编译器

9.5.1. Java 代码的执行分类

  • 第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行

  • 第二种是编译执行(直接编译成机器码,但是要知道不同机器上编译的机器码是不一样,而字节码是可以跨平台的)。现代虚拟机为了提高执行效率,会使用即时编译技术(JIT,Just In Time)将方法编译成机器码后再执行

HotSpot VM 是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在 Java 虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。

在今天,Java 程序的运行性能早已脱胎换骨,已经达到了可以和 C/C++ 程序一较高下的地步。

问题来了

有些开发人员会感觉到诧异,既然 HotSpot VM 中已经内置 JIT 编译器了,那么为什么还需要再使用解释器来“拖累”程序的执行性能呢?比如 JRockit VM 内部就不包含解释器,字节码全部都依靠即时编译器编译后执行。

首先明确: 当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。 编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。

所以: 尽管 JRockit VM 中程序的执行性能会非常高效,但程序在启动时必然需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点。在此模式下,当 Java 虚拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。

同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”。

9.5.2. HotSpot JVM 执行方式

解析器和JIT编译器结合使用

当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。

案例来了

注意解释执行与编译执行在线上环境微妙的辩证关系。机器在热机状态可以承受的负载要大于冷机状态。如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。

在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的 1/8。曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的 JVM 均是解释执行,还没有进行热点代码统计和 JIT 动态编译,导致机器启动之后,当前 1/2 发布成功的服务器马上全部宕机,此故障说明了 JIT 的存在。—阿里团队

image-20200710095417462

9.5.3. 概念解释

Java 语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把.java 文件转变成.class 文件的过程;

也可能是指虚拟机的后端运行期编译器(JIT 编译器,Just In Time Compiler)把字节码转变成机器码的过程。

还可能是指使用静态提前编译器(AOT 编译器,Ahead of Time Compiler)直接把.java 文件编译成本地机器代码的过程。

  • 前端编译器:Sun 的 Javac、Eclipse JDT 中的增量式编译器(ECJ)。

  • JIT 编译器:HotSpot VM 的 C1、C2 编译器。

  • AOT 编译器:GNU Compiler for the Java(GCJ)、Excelsior JET。

9.5.4. 热点代码及探测技术

当然是否需要启动 JIT 编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”,JIT 编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升 Java 程序的执行性能。

一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过 JIT 编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此被称之为栈上替换,或简称为OSR(On Stack Replacement)编译

一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值,JIT 编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能

目前 HotSpot VM 所采用的热点探测方式是基于计数器的热点探测

采用基于计数器的热点探测,HotSpot VM 将会为每一个方法都建立 2 个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)

  • 方法调用计数器用于统计方法的调用次数
  • 回边计数器则用于统计循环体执行的循环次数

方法调用计数器

这个计数器就用于统计方法被调用的次数,它的默认阀值在 Client 模式下是 1500 次,在 Server 模式下是 10000 次。超过这个阈值,就会触发 JIT 编译。

这个阀值可以通过虚拟机参数 -XX:CompileThreshold来人为设定。

当一个方法被调用时,会先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加 1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阀值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。

image-20200710101829934

热点衰减

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)

进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:-UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。

另外,可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

回边计数器

它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。显然,建立回边计数器统计的目的就是为了触发 OSR 编译。

image-20200710103103869

9.5.5. HotSpotVM 可以设置程序执行方法

缺省情况下 HotSpot VM 是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为 Java 虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:

  • -Xint:完全采用解释器模式执行程序;
  • -Xcomp:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行
  • -Xmixed:采用解释器+即时编译器的混合模式共同执行程序。

image-20221120214349955

例子:

通过设置三种不同的方式进行测试:

image-20221120214924904

设置方式如下:

image-20221120214714985

9.5.6. HotSpotVM 中 JIT 分类

JIT 的编译器还分为了两种,分别是 C1 和 C2,在 HotSpot VM 中内嵌有两个 JIT 编译器,分别为 Client Compiler 和 Server Compiler,但大多数情况下我们简称为 C1 编译器 和 C2 编译器。开发人员可以通过如下命令显式指定 Java 虚拟机在运行时到底使用哪一种即时编译器,如下所示:

  • -client:指定 Java 虚拟机运行在 Client 模式下,并使用 C1 编译器;C1 编译器会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度。
  • -server:指定 Java 虚拟机运行在 server 模式下,并使用 C2 编译器。C2进行耗时较长的优化,以及激进优化,但优化的代码执行效率更高。

分层编译(Tiered Compilation)策略:程序解释执行( 不开启性能监控)可以触发 C1 编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2 编译会根据性能监控信息进行激进优化。

不过在 Java7 版本之后,一旦开发人员在程序中显式指定命令“-server"时,默认将会开启分层编译策略,由 C1 编译器和 C2 编译器相互协作共同来执行编译任务。

C1 和 C2 编译器不同的优化策略

在不同的编译器上有不同的优化策略,C1 编译器上主要有方法内联、去虚拟化、冗余消除

  • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
  • 去虚拟化:对唯一的实现类进行内联
  • 冗余消除:在运行期间把一些不会执行的代码折叠掉

C2 的优化主要是在全局层面,逃逸分析(前面讲过,并不成熟)是优化的基础。基于逃逸分析在 C2 上有如下几种优化:

  • 标量替换:用标量值代替聚合对象的属性值
  • 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
  • 同步消除:清除同步操作,通常指 synchronized

image-20221120215856902

总结

一般来讲,JIT 编译出来的机器码性能比解释器高。

C2 编译器启动时长比 C1 慢,系统稳定执行以后,C2 编译器执行速度远快于 C1 编译器

写到最后 1

  • 自 JDK10 起,HotSpot 又加入了一个全新的及时编译器:Graal 编译器
  • 编译效果短短几年时间就追评了 C2 编译器,未来可期
  • 目前,带着实验状态标签,需要使用开关参数-XX:+UnlockExperimentalvMOptions -XX:+UseJVMCICompiler去激活才能使用

写到最后 2:AOT 编译器

jdk9 引入了 AOT 编译器(静态提前编译器,Ahead of Time Compiler)

Java 9 引入了实验性 AOT 编译工具 jaotc。它借助了 Graal 编译器,将所输入的 Java 类文件转换为机器码,并存放至生成的动态共享库之中。

所谓 AOT 编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而AOT 编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。

最大的好处:Java 虚拟机加载已经预编译成二进制库,可以直接执行。不必等待及时编译器的预热,减少 Java 应用给人带来“第一次运行慢” 的不良体验

缺点:

  • 破坏了 java “ 一次编译,到处运行”的理念,必须为每个不同的硬件,OS 编译对应的发行包
  • 降低了 Java 链接过程的动态性,加载的代码在编译器就必须全部已知。
  • 还需要继续优化中,最初只支持 Linux X64 java base

为什么不全部使用 AOT 呢?

AOT 可以提前编译节省启动时间,那为什么不全部使用这种编译方式呢?

长话短说,这和 Java 语言的动态特性有千丝万缕的联系了。举个例子,CGLIB 动态代理使用的是 ASM 技术,而这种技术大致原理是运行时直接在内存中生成并加载修改后的字节码文件也就是 .class 文件,如果全部使用 AOT 提前编译,也就不能使用 ASM 技术了。为了支持类似的动态特性,所以选择使用 JIT 即时编译器。

8. 对象实例化及直接内存

[toc]

8. 对象实例化及直接内存

8.1. 对象实例化

面试题

美团

对象在 JVM 中是怎么存储的?

对象头信息里面有哪些东西?

蚂蚁金服

Java 对象头有什么?

image-20221120122751656

8.1.1. 创建对象的方式

  • new:最常见的方式、Xxx 的静态方法,XxxBuilder/XxxFactory 的静态方法
  • Class 的 newInstance 方法:反射的方式,只能调用空参的构造器,权限必须是 public
  • Constructor 的 newInstance(XXX):反射的方式,可以调用空参、带参的构造器,权限没有要求
  • 使用 clone():不调用任何的构造器,要求当前的类需要实现 Cloneable 接口,实现 clone()
  • 使用序列化:从文件中、从网络中获取一个对象的二进制流
  • 第三方库 Objenesis

8.1.2. 创建对象的步骤

前面所述是从字节码角度看待对象的创建过程,现在从执行步骤的角度来分析:

image-20210510220743192

1. 判断对象对应的类是否加载、链接、初始化(即,要加载创建的对象所属的类)

虚拟机遇到一条 new 指令,首先去检查这个指令的参数能否在 Metaspace 的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化(即判断类元信息是否存在)。

如果没有,那么在双亲委派模式下,使用当前类加载器以 ClassLoader + 包名 + 类名为 key 进行查找对应的 .class 文件;

  • 如果没有找到文件,则抛出 ClassNotFoundException 异常
  • 如果找到,则进行类加载,并生成对应的 Class 对象

2. 为对象分配内存

首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即 4 个字节大小

如果内存规整:虚拟机将采用的是指针碰撞法(Bump The Point)来为对象分配内存。

  • 意思是所有用过的内存在一边,空闲的内存放另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针指向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是 Serial ,ParNew 这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带 Compact(整理)过程的收集器时,使用指针碰撞。

如果内存不规整:虚拟机需要维护一个空闲列表(Free List)来为对象分配内存。

  • 已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表来为对象分配内存。意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。

选择哪种分配方式由 Java 堆是否规整所决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

3. 处理并发问题

  • 采用 CAS 失败重试、区域加锁保证更新的原子性

    • ps : CAS(Compare-And-Swap),它是一条CPU并发原语,用于判断内存中某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。
  • 每个线程预先分配一块 TLAB:通过设置 -XX:+UseTLAB参数来设定

    • ps:TLAB是线程私有的,不会被其它线程共享使用,但是TLAB区域不大

4. 初始化分配到的内存

所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用

5. 设置对象的对象头

将对象的所属类(即类的元数据信息)、对象的 HashCode 和对象的 GC 信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于 JVM 实现。

6. 执行 init 方法进行初始化

在 Java 程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。

因此一般来说(由字节码中跟随 invokespecial 指令所决定),new 指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完成创建出来。

给对象属性赋值的操作

  • 属性的默认初始化
  • 显式初始化
  • 代码块中初始化
  • 构造器中初始化

对象实例化的过程

  1. 加载类元信息
  2. 为对象分配内存
  3. 处理并发问题
  4. 属性的默认初始化(零值初始化)
  5. 设置对象头信息
  6. 属性的显示初始化、代码块中初始化、构造器中初始化

8.2. 对象内存布局

image-20200709151033237

8.2.1. 对象头(Header)

对象头包含了两部分,分别是运行时元数据(Mark Word)类型指针。如果是数组,还需要记录数组的长度

运行时元数据

  • 哈希值(HashCode)
  • GC 分代年龄
  • 锁状态标志
  • 线程持有的锁
  • 偏向线程 ID
  • 翩向时间戳

类型指针

指向类元数据 InstanceKlass,确定该对象所属的类型。

8.2.2. 实例数据(Instance Data)

它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)

  • 相同宽度的字段总是被分配在一起
  • 父类中定义的变量会出现在子类之前
  • 如果 CompactFields 参数为 true(默认为 true):子类的窄变量可能插入到父类变量的空隙

8.2.3. 对齐填充(Padding)

不是必须的,也没有特别的含义,仅仅起到占位符的作用

举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Customer{
int id = 1001;
String name;
Account acct;

{
name = "匿名客户";
}

public Customer() {
acct = new Account();
}
}

public class CustomerTest{
public static void main(string[] args){
Customer cust=new Customer();
}
}

图示

image-20200709152801713

小结

image-20210510225407119

8.3. 对象的访问定位

image-20210510230045654

JVM 是如何通过栈帧中的对象引用访问到其内部的对象实例呢?

定位,通过栈上reference访问

image-20200709164149920

8.3.1. 句柄访问

image-20210510230241991

reference 中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference 本身不需要被修改

8.3.2. 直接指针(HotSpot 采用)

image-20210510230337956

直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据

直接指针的reference是不稳定的,可能发生变化的,因为reference指向的对象,在垃圾回收时,需要移动,所以reference就会跟着修改

8.4. 直接内存(Direct Memory)

8.4.1. 直接内存概述

不是虚拟机运行时数据区的一部分,也不是《Java 虚拟机规范》中定义的内存区域。直接内存是在 Java 堆外的、直接向系统申请的内存区间。来源于 NIO,通过存在堆中的 DirectByteBuffer 操作 Native 内存。通常,访问直接内存的速度会优于 Java 堆,即读写性能高

  • 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。
  • Java 的 NIO 库允许 Java 程序使用直接内存,用于数据缓冲区

8.4.2. 非直接缓存区

使用 IO 读写文件,需要与磁盘交互,需要由用户态切换到内核态。在内核态时,需要两份内存存储重复数据,效率低。

image-20210510231408607

8.4.3. 直接缓存区

使用 NIO 时,操作系统划出的直接缓存区可以被 java 代码直接访问,只有一份。NIO 适合对大文件的读写操作。

image-20210510231456550

也可能导致 OutOfMemoryError 异常

1
2
3
4
5
Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
at java.nio.Bits.reserveMemory(Bits.java:693)
at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
at com.atguigu.java.BufferTest2.main(BufferTest2.java:20)

由于直接内存在 Java 堆外,因此它的大小不会直接受限于-Xmx 指定的最大堆大小,但是系统内存是有限的,Java 堆和直接内存的总和依然受限于操作系统能给出的最大内存。

  • 分配回收成本较高
  • 不受 JVM 内存回收管理

直接内存大小可以通过MaxDirectMemorySize设置。如果不指定,默认与堆的最大值-Xmx 参数值一致

image-20200709230647277

java总的进程空间 = java的堆空间 + 本地内存空间

运行时数据区的总结以及常见大厂面试题

运行时数据区的总结以及常见大厂面试题

image-20200708220303243

线程私有的:程序计数器、本地方法栈、虚拟机栈

虚拟机栈里的栈帧的结构:返回值、局部变量表、操作数栈、动态链接(装着指向运行时常量池的当前方法的引用,知道当前方法是引用运行时常量池中的哪个方法)

image-20221120105434202

常见面试题

百度

说一下 JVM 内存模型吧,有哪些区?分别干什么的?

答:

JVM内存区域主要包含:方法区 程序计数器 Java虚拟机栈 本地方法栈 Java堆

1、方法区:

方法区在JDK1.8之后把名字改成了“Metaspace",可以翻译成"元数据空间",这是一块线程共享的内存空间,主要用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。

Java虚拟机规范将方法区描述为堆的一个逻辑部分,但它有一个别名”Non-Heap 非堆“ 用于与Java堆区分。

很多人愿意把方法区成为”永久代“,本质上两者并不等价。HotSpot虚拟机 团队选择把GC分代收集扩展至方法区,或者说使用永久带实现方法区而已。

方法区存在的问题

永久代容易遇到内存溢出问题(HotSpot永久代有-XX:MaxPermSize上限)当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常运行时常量池也是方法区的一个部分,主要用于存放编译器生成的各种字面量和符号引用。类加载后会进入方法区的运行时常量池

运行时常量池存在的问题:

当常量池无法再申请到内存空间时会抛出OutOfMemoryError异常

2、虚拟机栈

虚拟机栈是线程私有的内存空间,其生命周期与线程相同,是用于Java方法执行的内存模型。方法执行时会创建栈帧,用于存储局部变量表,操作数栈,动态链接,方法出口等。

虚拟机栈存在的问题:

线程请求的栈深度大于虚拟机所允许的深度,抛出StackOverflowError异常虚拟机栈动态扩展时无法申请到足够的内存,抛出OutOfMemoryError异常

3、本地方法栈

其发挥的作用和存在的问题与Java虚拟机栈类似,这里主要说一下其区别。其区别主要是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈为虚拟机使用到Native方法服务。

**知识点:**Hotspot虚拟机中将本地方法栈与虚拟机栈合二为一。

4、Java堆

Java堆是虚拟机中占内存最大的一块内存空间,是所有线程共享的内存区域,当虚拟机启动的时候就会创建。它的作用主要是存放对象实例,几乎所有的对象实例都在这里分配内存。我们来看下Java虚拟机规范是怎么描述它的:

所有的对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配到堆上也渐渐变得不是那么”绝对“了。

这一块内存空间也是垃圾收集器管理的主要区域,所以有时候也被称为”GC堆“。

Java堆存在的问题:

如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

5、程序计数器

程序计数器是线程私有的一块占用较小的内存空间,主要用于记录当前线程执行到哪里了。而且这也是唯一一个没有内存溢出的区域。

6、直接内存

除了以上几块内存空间外,还有一块内存空间就是直接内存,它不属于运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分频繁使用也可能导致OutOfMemoryError异常。NIO可以使用Native函数库直接分配堆外内存空间。

文末小结

关于jvm内存区域空间要重点关注方法区,程序计数器,Java虚拟机栈Java堆这些内存区域的作用。只有在了解了虚拟机是怎么使用内存的之后,才能在出现内存溢出和泄漏时更快速的定位和排查解决问题。

蚂蚁金服

Java8 的内存分代改进 JVM 内存分哪几个区,每个区的作用是什么?

栈和堆的区别?

答:

  1. 栈(stack)与堆(heap)都是Java用来在Ram(随机存储内存)中存放数据的地方。Java自动管理栈和堆,程序员不能直接地设置栈或堆。

  2. java内存的划分

    (1)栈内存:基本类型的变量和对象的引用变量

                       优势:存取速度比堆要快,仅次于直接位于CPU中的寄存器;  栈数据可以共享
    
                       劣势:存在栈中的数据大小与生存期必须是确定的,缺乏灵活性
    

    (2)堆内存:存放由new创建的对象和数组

                       优势:动态地分配内存大小,生存期也不必事先告诉编译器,Java的垃圾收集器会自动收走这些不再使用的数据
    
                       劣势:由于要在运行时动态分配内存,存取速度较慢
    
  3. 内存的释放:

    (1)栈内存: 当在一段代码块定义一个变量时,Java就在栈中为这个变量分配内存空间,当超过变量的作用域后,Java会自动释放掉为该变量所分配的内存空间,该内存空间可以立即被另作他用。
    
     (2)堆内存:由Java虚拟机的自动垃圾回收器来管理。
    

一面:JVM 内存分布/内存结构?栈和堆的区别?堆的结构?为什么两个 survivor 区?

二面:Eden 和 survior 的比例分配

答:8 : 1 : 1

小米

jvm 内存分区,为什么要有新生代和老年代

答:

其实不分代完全可以,分代的唯一理由就是优化 GC 性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC 的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当 GC 的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

答案一:

对于JVM而言,大部分对象都是属于一个朝生夕死的状态,这部分对象随着方法的调用而创建,方法的结束而消亡,只有少部分的对象会长久的留在JVM 内存中,所以根据这样的特性JVM 把内存分为了新生代 和老年代两个区,一般情况新创建的对象会放到新生代中,只有经过一定次数的GC后还没有被回收的对象,我们认为这部分对象在未来也会长时间存在,所以会把这部分的对象转移到老年代的区域中去。

答案二:

1)新生代(Young Gen):年轻代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。年轻代分成1个Eden Space和2个Suvivor Space(from 和to)。
2)老年代(Tenured Gen):年老代主要存放JVM认为生命周期比较长的对象(经过几次的Young Gen的垃圾回收后仍然存在),内存大小相对会比较大,垃圾回收也相对没有那么频繁。

字节跳动

二面:Java 的内存分区

二面:讲讲 vm 运行时数据库区 什么时 候对象会进入老年代?

答:

  1. 长期存活的对象:虚拟机给每个对象定义了一个对象年龄(Age)计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1,。对象在Survivor区中每熬过一次Minor GC,年龄就增加1,当他的年龄增加到一定程度(默认是15岁), 就将会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

  2. 当遇到超大对象时,发现新生代中的Eden区(即便进行了Minor GC)放不下,就会直接尝试放到老年代Tenured/OId,如果老年代也放不下,就触发Major GC 或 Full GC,之后老年代区能放得下就放,不能的话就报OOM。大对象对虚拟机的内存分配就是坏消息,尤其是一些朝生夕灭的短命大对象,写程序时应避免。

  3. 动态对象年龄判定:为了能更好地适应不同程度的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升到老年代,如果在Survivor空间中相同年龄的所有对象大小的总和大于Survivor空间的一半, 年龄大于或等于年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

京东

JVM 的内存结构,Eden 和 Survivor 比例。 8 :1 :1

JVM 内存为什么要分成新生代,老年代,持久代。

新生代中为什么要分为 Eden 和 survivor。

答:

  1. 如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,触发Major GC.老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多,大概是Minor GC的十倍以上, 如果Major GC触发次数多的话,就会降低性能,所以要尽可能避免或减少老年代触发Major GC。因此需要分为Eden和Survivor。

  2. Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。

  3. 设置两个Survivor区最大的好处就是解决了碎片化,刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor spaceS1(这个过程非常重要,因为这种 复制算法 保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生

天猫

一面:Jvm 内存模型以及分区,需要详细到每个区放什么。

一面:JVM 的内存模型,Java8 做了什么改

答:

JDK1.6 及之前 有永久代(permanet),静态变量存储在永久代上
JDK1.7 有永久代,但已经逐步 “去永久代”,字符串常量池,静态变量移除,保存在堆中
JDK1.8 无永久代,类型信息,字段,方法,常量保存在本地内存的元空间,但字符串常量池、静态变量仍然在堆中。

其中,String Table之所以要调整位置:

jdk7 中将 StringTable 放到了堆空间中。因为永久代的回收效率很低,在 full gc 的时候才会触发。而 full gc 是老年代的空间不足、永久代不足时才会触发。

这就导致 StringTable 回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。

拼多多

JVM 内存分哪几个区,每个区的作用是什么?

美团

java 内存分配 jvm 的永久代中会发生垃圾回收吗?

一面:jvm 内存分区,为什么要有新生代和老年代?

代理模式

结构型模式

结构型模式描述如何将类或对象按某种布局组成更大的结构。它分为类结构型模式和对象结构型模式,前者采用继承机制来组织接口和类,后者釆用组合或聚合来组合对象。

由于组合关系或聚合关系比继承关系耦合度低,满足“合成复用原则”,所以对象结构型模式比类结构型模式具有更大的灵活性。

结构型模式分为以下 7 种:

  • 代理模式
  • 适配器模式
  • 装饰者模式
  • 桥接模式
  • 外观模式
  • 组合模式
  • 享元模式

1 代理模式

1 概述

由于某些原因需要给某对象提供一个代理以控制对该对象的访问。这时,访问对象不适合或者不能直接引用目标对象,代理对象作为访问对象和目标对象之间的中介。

Java中的代理按照代理类生成时机不同又分为静态代理和动态代理。静态代理代理类在编译期就生成,而动态代理代理类则是在Java运行时动态生成。动态代理又有JDK代理和CGLib代理两种。

2 结构

代理(Proxy)模式分为三种角色:

  • 抽象主题(Subject)类: 通过接口或抽象类声明真实主题和代理对象实现的业务方法。
  • 真实主题(Real Subject)类: 实现了抽象主题中的具体业务,是代理对象所代表的真实对象,是最终要引用的对象。
  • 代理(Proxy)类 : 提供了与真实主题相同的接口,其内部含有对真实主题的引用,它可以访问、控制或扩展真实主题的功能。

3 静态代理

我们通过案例来感受一下静态代理。

【例】火车站卖票

如果要买火车票的话,需要去火车站买票,坐车到火车站,排队等一系列的操作,显然比较麻烦。而火车站在多个地方都有代售点,我们去代售点买票就方便很多了。这个例子其实就是典型的代理模式,火车站是目标对象,代售点是代理对象。类图如下:

代码如下:

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 interface SellTickets {
void sell();
}

//火车站 火车站具有卖票功能,所以需要实现SellTickets接口
public class TrainStation implements SellTickets {

public void sell() {
System.out.println("火车站卖票");
}
}

//代售点
public class ProxyPoint implements SellTickets {

private TrainStation station = new TrainStation();

public void sell() {
System.out.println("代理点收取一些服务费用");
station.sell();
}
}

//测试类
public class Client {
public static void main(String[] args) {
ProxyPoint pp = new ProxyPoint();
pp.sell();
}
}Copy to clipboardErrorCopied

image-20221119220401804

从上面代码中可以看出测试类直接访问的是ProxyPoint类对象,也就是说ProxyPoint作为访问对象和目标对象的中介。同时也对sell方法进行了增强(代理点收取一些服务费用)。

4 JDK动态代理

接下来我们使用动态代理实现上面案例,先说说JDK提供的动态代理。Java中提供了一个动态代理类Proxy,Proxy并不是我们上述所说的代理对象的类,而是提供了一个创建代理对象的静态方法(newProxyInstance方法)来获取代理对象。

代码如下:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
//卖票接口
public interface SellTickets {
void sell();
}

//火车站 火车站具有卖票功能,所以需要实现SellTickets接口
public class TrainStation implements SellTickets {

public void sell() {
System.out.println("火车站卖票");
}
}

//代理工厂,用来创建代理对象
public class ProxyFactory {

private TrainStation station = new TrainStation();

public SellTickets getProxyObject() {
//使用Proxy获取代理对象
/*
newProxyInstance()方法参数说明:
ClassLoader loader : 类加载器,用于加载代理类,使用真实对象的类加载器即可
Class<?>[] interfaces : 真实对象所实现的接口,代理模式真实对象和代理对象实现相同的接口
InvocationHandler h : 代理对象的调用处理程序
*/
SellTickets sellTickets = (SellTickets) Proxy.newProxyInstance(station.getClass().getClassLoader(),
station.getClass().getInterfaces(),
new InvocationHandler() {
/*
InvocationHandler中invoke方法参数说明:
proxy : 代理对象
method : 对应于在代理对象上调用的接口方法的 Method 实例
args : 代理对象调用接口方法时传递的实际参数
*/
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

System.out.println("代理点收取一些服务费用(JDK动态代理方式)");
//执行真实对象
Object result = method.invoke(station, args);
return result;
}
});
return sellTickets;
}
}

//测试类
public class Client {
public static void main(String[] args) {
//获取代理对象
ProxyFactory factory = new ProxyFactory();

SellTickets proxyObject = factory.getProxyObject();
proxyObject.sell();
}
}Copy to clipboardErrorCopied

使用了动态代理,我们思考下面问题:

  • ProxyFactory是代理类吗?

    ProxyFactory不是代理模式中所说的代理类,而代理类是程序在运行过程中动态的在内存中生成的类。通过阿里巴巴开源的 Java 诊断工具(Arthas【阿尔萨斯】)查看代理类的结构:

    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
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    package com.sun.proxy;

    import com.itheima.proxy.dynamic.jdk.SellTickets;
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Method;
    import java.lang.reflect.Proxy;
    import java.lang.reflect.UndeclaredThrowableException;

    public final class $Proxy0 extends Proxy implements SellTickets {
    private static Method m1;
    private static Method m2;
    private static Method m3;
    private static Method m0;

    public $Proxy0(InvocationHandler invocationHandler) {
    super(invocationHandler);
    }

    static {
    try {
    m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
    m2 = Class.forName("java.lang.Object").getMethod("toString", new Class[0]);
    m3 = Class.forName("com.itheima.proxy.dynamic.jdk.SellTickets").getMethod("sell", new Class[0]);
    m0 = Class.forName("java.lang.Object").getMethod("hashCode", new Class[0]);
    return;
    }
    catch (NoSuchMethodException noSuchMethodException) {
    throw new NoSuchMethodError(noSuchMethodException.getMessage());
    }
    catch (ClassNotFoundException classNotFoundException) {
    throw new NoClassDefFoundError(classNotFoundException.getMessage());
    }
    }

    public final boolean equals(Object object) {
    try {
    return (Boolean)this.h.invoke(this, m1, new Object[]{object});
    }
    catch (Error | RuntimeException throwable) {
    throw throwable;
    }
    catch (Throwable throwable) {
    throw new UndeclaredThrowableException(throwable);
    }
    }

    public final String toString() {
    try {
    return (String)this.h.invoke(this, m2, null);
    }
    catch (Error | RuntimeException throwable) {
    throw throwable;
    }
    catch (Throwable throwable) {
    throw new UndeclaredThrowableException(throwable);
    }
    }

    public final int hashCode() {
    try {
    return (Integer)this.h.invoke(this, m0, null);
    }
    catch (Error | RuntimeException throwable) {
    throw throwable;
    }
    catch (Throwable throwable) {
    throw new UndeclaredThrowableException(throwable);
    }
    }

    public final void sell() {
    try {
    this.h.invoke(this, m3, null);
    return;
    }
    catch (Error | RuntimeException throwable) {
    throw throwable;
    }
    catch (Throwable throwable) {
    throw new UndeclaredThrowableException(throwable);
    }
    }
    }Copy to clipboardErrorCopied

    从上面的类中,我们可以看到以下几个信息:

    • 代理类($Proxy0)实现了SellTickets。这也就印证了我们之前说的真实类和代理类实现同样的接口。
    • 代理类($Proxy0)将我们提供了的匿名内部类对象传递给了父类。
  • 动态代理的执行流程是什么样?

    下面是摘取的重点代码:

    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
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    //程序运行过程中动态生成的代理类
    public final class $Proxy0 extends Proxy implements SellTickets {
    private static Method m3;

    public $Proxy0(InvocationHandler invocationHandler) {
    super(invocationHandler);
    }

    static {
    m3 = Class.forName("com.itheima.proxy.dynamic.jdk.SellTickets").getMethod("sell", new Class[0]);
    }

    public final void sell() {
    this.h.invoke(this, m3, null);
    }
    }

    //Java提供的动态代理相关类
    public class Proxy implements java.io.Serializable {
    protected InvocationHandler h;

    protected Proxy(InvocationHandler h) {
    this.h = h;
    }
    }

    //代理工厂类
    public class ProxyFactory {

    private TrainStation station = new TrainStation();

    public SellTickets getProxyObject() {
    SellTickets sellTickets = (SellTickets) Proxy.newProxyInstance(station.getClass().getClassLoader(),
    station.getClass().getInterfaces(),
    new InvocationHandler() {

    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

    System.out.println("代理点收取一些服务费用(JDK动态代理方式)");
    Object result = method.invoke(station, args);
    return result;
    }
    });
    return sellTickets;
    }
    }


    //测试访问类
    public class Client {
    public static void main(String[] args) {
    //获取代理对象
    ProxyFactory factory = new ProxyFactory();
    SellTickets proxyObject = factory.getProxyObject();
    proxyObject.sell();
    }
    }Copy to clipboardErrorCopied

执行流程如下:

  1. 在测试类中通过代理对象调用sell()方法
  2. 根据多态的特性,执行的是代理类($Proxy0)中的sell()方法
  3. 代理类($Proxy0)中的sell()方法中又调用了InvocationHandler接口的子实现类对象的invoke方法
  4. invoke方法通过反射执行了真实对象所属类(TrainStation)中的sell()方法

5.1.5 CGLIB动态代理

同样是上面的案例,我们再次使用CGLIB代理实现。

如果没有定义SellTickets接口,只定义了TrainStation(火车站类)。很显然JDK代理是无法使用了,因为JDK动态代理要求必须定义接口,对接口进行代理。

CGLIB是一个功能强大,高性能的代码生成包。它为没有实现接口的类提供代理,为JDK的动态代理提供了很好的补充。

CGLIB是第三方提供的包,所以需要引入jar包的坐标:

1
2
3
4
5
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib</artifactId>
<version>2.2.2</version>
</dependency>Copy to clipboardErrorCopied

代码如下:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
//火车站
public class TrainStation {

public void sell() {
System.out.println("火车站卖票");
}
}

//代理工厂
public class ProxyFactory implements MethodInterceptor {

private TrainStation target = new TrainStation();

public TrainStation getProxyObject() {
//创建Enhancer对象,类似于JDK动态代理的Proxy类,下一步就是设置几个参数
Enhancer enhancer =new Enhancer();
//设置父类的字节码对象
enhancer.setSuperclass(target.getClass());
//设置回调函数
enhancer.setCallback(this);
//创建代理对象
TrainStation obj = (TrainStation) enhancer.create();
return obj;
}

/*
intercept方法参数说明:
o : 代理对象
method : 真实对象中的方法的Method实例
args : 实际参数
methodProxy :代理对象中的方法的method实例
*/
public TrainStation intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
System.out.println("代理点收取一些服务费用(CGLIB动态代理方式)");
TrainStation result = (TrainStation) methodProxy.invokeSuper(o, args);
return result;
}
}

//测试类
public class Client {
public static void main(String[] args) {
//创建代理工厂对象
ProxyFactory factory = new ProxyFactory();
//获取代理对象
TrainStation proxyObject = factory.getProxyObject();

proxyObject.sell();
}
}Copy to clipboardErrorCopied

5.1.6 三种代理的对比

  • jdk代理和CGLIB代理

    使用CGLib实现动态代理,CGLib底层采用ASM字节码生成框架,使用字节码技术生成代理类,在JDK1.6之前比使用Java反射效率要高。唯一需要注意的是,CGLib不能对声明为final的类或者方法进行代理,因为CGLib原理是动态生成被代理类的子类。

    在JDK1.6、JDK1.7、JDK1.8逐步对JDK动态代理优化之后,在调用次数较少的情况下,JDK代理效率高于CGLib代理效率,只有当进行大量调用的时候,JDK1.6和JDK1.7比CGLib代理效率低一点,但是到JDK1.8的时候,JDK代理效率高于CGLib代理。所以如果有接口使用JDK动态代理,如果没有接口使用CGLIB代理。

  • 动态代理和静态代理

    动态代理与静态代理相比较,最大的好处是接口中声明的所有方法都被转移到调用处理器一个集中的方法中处理(InvocationHandler.invoke)。这样,在接口方法数量比较多的时候,我们可以进行灵活处理,而不需要像静态代理那样每一个方法进行中转。

    如果接口增加一个方法,静态代理模式除了所有实现类需要实现这个方法外,所有代理类也需要实现此方法。增加了代码维护的复杂度。而动态代理不会出现该问题

5.1.7 优缺点

优点:

  • 代理模式在客户端与目标对象之间起到一个中介作用和保护目标对象的作用;
  • 代理对象可以扩展目标对象的功能;
  • 代理模式能将客户端与目标对象分离,在一定程度上降低了系统的耦合度;

缺点:

  • 增加了系统的复杂度;

5.1.8 使用场景

  • 远程(Remote)代理

    本地服务通过网络请求远程服务。为了实现本地到远程的通信,我们需要实现网络通信,处理其中可能的异常。为良好的代码设计和可维护性,我们将网络通信部分隐藏起来,只暴露给本地服务一个接口,通过该接口即可访问远程服务提供的功能,而不必过多关心通信部分的细节。

  • 防火墙(Firewall)代理

    当你将浏览器配置成使用代理功能时,防火墙就将你的浏览器的请求转给互联网;当互联网返回响应时,代理服务器再把它转给你的浏览器。

  • 保护(Protect or Access)代理

    控制对一个对象的访问,如果需要,可以给不同的用户提供不同级别的使用权限。

创建者模式对比

创建者模式对比

工厂方法模式VS建造者模式

工厂方法模式注重的是整体对象的创建方式;而建造者模式注重的是部件构建的过程,意在通过一步一步地精确构造创建出一个复杂的对象。

我们举个简单例子来说明两者的差异,如要制造一个超人,如果使用工厂方法模式,直接产生出来的就是一个力大无穷、能够飞翔、内裤外穿的超人;而如果使用建造者模式,则需要组装手、头、脚、躯干等部分,然后再把内裤外穿,于是一个超人就诞生了。

抽象工厂模式VS建造者模式

抽象工厂模式实现对产品家族的创建,一个产品家族是这样的一系列产品:具有不同分类维度的产品组合,采用抽象工厂模式则是不需要关心构建过程,只关心什么产品由什么工厂生产即可。

建造者模式则是要求按照指定的蓝图建造产品,它的主要目的是通过组装零配件而产生一个新产品。

如果将抽象工厂模式看成汽车配件生产工厂,生产一个产品族的产品,那么建造者模式就是一个汽车组装工厂,通过对部件的组装可以返回一辆完整的汽车。