Java虚拟机

1、JVM架构

java源码通过javac编译为java字节码,java字节码是java虚拟机执行的一套代码格式,抽象了计算机的基本操作。大多数指令只有一个字节,有些操作符需要参数,导致多使用了一些字节。

java虚拟机

JVM的基础架构如上图所示:主要包含三大块:

  • 类加载器:负责动态加载JAVA类到JAVA虚拟机的内存空间。
  • 运行时数据区:存储JVM运行时的所有数据。
  • 执行引擎:提供JVM在不同平台的运行能力。

线程

在JVM中运行着很多线程,一部分是程序创建来执行代码逻辑的应用线程,剩下的是JVM创建来执行后台任务的系统线程

主要的系统线程:

  • Compile:运行时将字节码编译为本地代码使用的线程。
  • GC:包含所有和GC有关的操作。
  • Period Task:JVM周期性任务调度的线程,主要包含JVM内部的采样分析。
  • Singal Dispatcher:处理OS发来的信号。
  • VM:某些操作需要等待 JVM 到达 安全点(Safe Point),即堆区没有变化。比如:GC 操作、线程 Dump、线程挂起 这些操作都在 VM Thread 中进行。

类型来分,JVM内部有两种线程:

  • 守护线程:JVM自己使用,程序也可以把自己的线程标记为守护线程(public final void setDaemon(boolean on),必须在start()方法之前调用。
  • 非守护线程:main方法执行的线程,也称为用户线程。

只要有非守护进程运行,java程序就会继续运行,非守护都终止时,虚拟机也会自动退出。

守护进程不适合进行IO,计算等操作,因为守护进程在非守护进程结束自动释放,不能判断该守护进程是否完成了操作。

2、类加载器

负责动态加载类到虚拟机的内存空间。通常是按需加载,类第一次使用才加载。有了类加载器,java在运行时系统不需要知道文件与文件系统。每个java类都要由类加载器装入到内存。

除了定位和导入二进制文件,还验证类的正确性,为变量分配初始化内存,帮助解析符号引用。按照以下顺序完成:

  • 装载:查找并装载二进制数据。
  • 链接:执行验证、准备、解析。
    • 验证:确保被导入类型的正确性。
    • 准备:为类变量分配内存,并将其初始化为默认值。
    • 解析:把类型中的符号引用转化为直接引用。
  • 初始化:把类变量初始化为正确的初始值。

1.装载

多个类装载器,应用程序可以使用两种类装载器:

  • Bootstrap ClassLoader:原生C编写,不继承自java.lang.ClassLoder。加载核心类库,启动类加载器通常使用某种默认的方式从本地磁盘中加载,包括java API。
  • Extention Classloader:用来在<JAVA_HOME>/jre/lib/ext ,或 java.ext.dirs 中指明的目录中加载 Java 的扩展库。 Java 虚拟机的实现会提供一个扩展库目录。
  • Application Classloader:根据应用程序的类路径java.class.path或者CLASSPATH加载类。一般来说,java应用的类它加载。可以通过ClassLoader.getSystemClassLoader() 来获取它。
  • 自定义类加载器:继承java.lang.ClassLoder来实现自己的类加载器。

全盘负责双亲委托机制:

一个JVM系统至少3种类加载器,如何平配合工作?通过全盘负责双亲委托机制来协调类加载器。

  • 全盘负责:当一个ClassLoder装载一个类时,除非显示的使用另一个,该类及其所依赖的类都用这个装载。
  • 双亲委托机制:指先委托父装载器寻找目标类,只有在找不到的情况下才从自己的类路径中查找并装载目标类。

全盘负责双亲委托是java推荐的机制,不是强制。实现自己的类加载器,保持机制,重写findClass(name)方法;破坏此机制,重写loadClass(name)方法。

装载入口:

所有java虚拟机实现必须在每个类或者接口首次主动使用时初始化。以下情况符合主动使用的要求:

  • 创建某个类的新实例(new、反射、克隆、序列化)
  • 调用某个类的静态方法
  • 使用类或者接口的静态字段,或对该字段赋值。final
  • 调用java API 的某些反射方法时。
  • 初始化某个类的子类时、
  • 当虚拟机启动时被表明为自动类的类。

除了以上全是被动,不会导致java类型的初始化。

对于接口来说,只有在此接口声明的非常量字段被使用,才会初始化,不会因为事先这个接口的子接口或者类要初始化而被初始化。

父类需要在子类之前初始化。当实现了接口的类被初始化时,不需要初始化父接口。然而,当实现了父接口的子类(或者拓展了父接口的子接口)被装载时,父接口也要被装载(装载但不实例化)。

2.链接

验证

确保装载后的类型符合java语言的语义,并且不会危害整个虚拟机的完整性。

  • 装载时验证:检查二进制数据保证是预期格式。确保除object外的每个类都有父类,确保该类的父类已经被装载。
  • 正式验证阶段:检查final类不能有子类,确保 final 方法不被覆盖、确保在类型和超类型之间没有不兼容的方法声明(比如拥有两个名字相同的方法,参数在数量、顺序、类型上都相同,但返回类型不同)。
  • 符号引用的验证:当虚拟机搜寻一个被符号引用的元素(类型、字段或方法)时,必须首先确认该元素存在。如果虚拟机发现元素存在,则必须进一步检查引用类型有访问该元素的权限。

准备

虚拟机为类变量分配内存,设置默认初始值。初始化阶段之前,类变量都没有被初始化为真正的初始值。

类型 默认值
int 0
long 0L
short (short)0
char ‘\u0000’
byte (byte)0
blooean false
float 0.0f
double 0.0d
reference null

解析

直白一点就是,将符号引用转化为直接引用,比如,将package com.source….转化为物理地址。

在类型的常量池中寻找类、接口、字段和方法的符号占用,把这些符号引用替换成直接引用的过程。

  • 类、接口的解析:判断所要转化成的直接引用是数组类型,还是普通的对象类型的引用,从而进行不同的解析。
  • 字段解析:对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束。

3.初始化

所有类变量(静态量)初始化语句和类型的静态初始化器都被java编译器收集在一起,放到一个特殊的方法。对于类来说,方法叫初始化方法;对于接口来说,称为接口初始化方法。在类和接口的class文件中,称为<clinit>

  1. 存在直接父类且父类没被初始化,先初始化直接父类。
  2. 类存在一个类初始化方法, 执行此方法。

递归执行,所以第一个初始化的类一定是object。

虚拟机须确保初始化过程正确同步。如果多个县城需要初始化一个类,仅仅允许一个,其余的等待。

Clinit方法

  • 对于静态变量和静态初始化语句来说:执行的顺序和它们在类或接口中出现的顺序有关。
  • 并非所有的类都需要在它们的class文件中拥有<clinit>()方法, 如果类没有声明任何类变量,也没有静态初始化语句,那么它就不会有<clinit>()方法。如果类声明了类变量,但没有明确的使用类变量初始化语句或者静态代码块来初始化它们,也不会有<clinit>()方法。如果类仅包含静态final常量的类变量初始化语句,而且这些类变量初始化语句采用编译时常量表达式,类也不会有<clinit>()方法。**只有那些需要执行Java代码来赋值的类才会有<clinit>()**’
  • final常量:Java虚拟机在使用它们的任何类的常量池或字节码中直接存放的是它们表示的常量值。

3、内存模型

运行时数据区保存JVM在运行过程中产生的数据。

java虚拟机-第 2 页

Heap

各线程共享的内存区域,是虚拟机管理内存区域最大的一块。几乎所有的对象实例和数组实例都是在堆上分配,但是对着JIT编译器以及逃逸分析技术的发展,也可能被优化为在栈上分配。

还包含字符串字面量常量池。包含一个新生代,一个老年代。

新生代三个区,大部分对象在Eden去生成,survivor总有一个是空的。

老年代保存一些生命周期比较长的对象,当一个对象经过对此GC还没被回收,将移动到老年代。

Method Area

方法区的数据所有线程共享,为安全使用方法区的数据,需要注意线程安全问题。

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。

主要保存类级别的数据,包括:

  • ClassLoader Reference
  • Runtime Constant Pool
    • 数字常量
    • 类属性引用
    • 方法引用

在JVM1.8之前,方法区的实现为永久代。经常内存溢出。在JVM1.8方法区的实现改为元空间,元空间是在Native的一块内存空间。

Stack

JVM线程启动时,分配独立的运行时栈,来保存方法调用。每个方法调用,都会入栈一个栈帧。

栈帧保存三个引用:本地变量表操作数帧和当前方法所属类的运行时常量池。由于本地变量表和操作数栈的大小都在编译时确定,所以栈帧的大小是固定的。

被调用的方法返回或抛出异常,栈帧弹出。栈帧内的数据是线程安全的。

栈大小可以动态拓展,但是如果一个线程需要栈的大小超出允许的大小,抛出StackOverflowError

PC Register

对于每个JVM线程,线程启动时有一个独立的PC计数器,保存当前执行的代码地址。如果是Native方法, PC的值是NULL。一旦执行完成,PC计数器会被更新为下一个需要执行代码的地址。

Native Method Stack

本地方法栈执行的是native方法。native方法就是C++写的方法。

Direct Memory

可以直接调用的内存。堆外内存。D可以使用堆外内存。

在 JDK 1.4 中新加入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为 避免了在 Java 堆和 Native 堆中来回复制数据

4、垃圾收集

对象存活检测

垃圾回收器回收内存之前,先要确定哪些对象是活的,哪些对象可以回收。

  • 引用计数算法

    对象添加引用计数器,引用时+1,引用失效-1。致命缺陷就是两个对象相互引用导致两个都无法回收。

  • 根搜索算法

    实际上是追踪从根节点开始的引用图。通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。

    在根搜索算法追踪的过程,起点是GC Root,根据JVM的实现不同而不同,但是总会包含以下几个方面(堆外引用):

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

    从GC Root开始的引用图,有向图,节点时对象,边是引用类型。JVM分为四种引用类型:强引用,软引用,弱引用,虚引用。

    一个对象的引用类型有多个,如何确定回收策略:

    • 单条引用链以链上最弱的一个引用类型来决定;
    • 多条引用链以多个单条引用链中最强的一个引用类型来决定;

    在引用图,如果一个节点没有任何路径可达,确定回收。

    强引用:

    在java中普遍存在,类似于Object o = new Object;和其他引用的区别是:强引用禁止引用目标被GC收集,其他引用不禁止。

    软引用:

    JVM 的实现需要在抛出 OutOfMemoryError 之前清除 SoftReference,但在其他的情况下可以选择清理的时间或者是否清除它们。

    有用但不是必须,使用java.lang.red.SoftReferencr类表示,这个特性很适合做缓存,比如:网页缓存,图片缓存。

    弱引用:

    垃圾收集器在GC的时候会回收所有的弱引用,如果该弱引用和引用队列相关联,他会把该弱引用加入到队列。

    虚引用:

    “虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。

垃圾回收算法

1、复制回收

可用内存等分两份,同一时刻值使用其中一份。用完后将还存活的对象赋值到另一份,然后将这一份清空。能够有效避免内存碎片,但是减低了内存使用率。

解决了标记清除回收可能会产生大量不连续内存的问题。

2、标记清除算法

先暂停整个程序的全部运行线程,让回收线程以单线程进行扫描标记,并进行直接清除回收,然后回收完成后恢复运行线程。标记清除后会产生大量不连续的内存碎片,造成空间浪费。

3、标记整理算法

和标记清除相似,不同的是,回收期间同时会将保留的存储对象搬运汇集到连续的内存空间,从而集成空闲时间。

4、增量回收

将程序拥有的内存空间分成若干区。程序的存储对象会分布在分区中,每次只对其中一个分区进行回收,从而避免程序全部运行线程暂停来进行回收,允许部分线程在不影响回收行为的情况下保持运行,降低回收时间,增加线程响应速度。

5、分代回收

对象拥有不同的生命周期,不同生命周期的对象采用不同的回收算法,以提高效率。

记忆集:

对象C在新生代,只被一个在老年代的D引用。如果运行新生代GC,要确定C是否被堆外引用,需要遍历老年代,代价比较大。JVM在对象引用的时候,会有个记忆集,记录从老年代到新生代的引用关系,并把记忆集中的老年代作为GC ROOT构建索引图,这样在新生代GC的时候就不需要遍历老年代。

但是记忆集有缺点:C & D 其实都可以进行回收,但是由于记忆集的存在,不会将 C 回收。这里其实有一点 空间换时间 的意思。不过无论如何,它依然确保了垃圾回收所遵循的原则:垃圾回收确保回收的对象必然是不可达对象,但是不确保所有的不可达对象都会被回收

垃圾回收触发条件

1、堆内内存

对于HotSpot VM实现,GC只有两大种:

  1. Partial GC:并不收集整个GC堆
    • Young GC:只收集新生代的GC
    • Old GC:只收集老年代的GC。只有 CMS的 Concurrent Collection 是这个模式
    • Mixed GC:收集整个 Young Gen 以及部分 Old Gen 的 GC。只有 G1 有这个模式
  2. Full GC:收集整个堆,包括 Young Gen、Old Gen、Perm Gen(如果存在的话)等所有部分的 GC 模式。

最简单的分代式GC策略,按照HotSpot的serial GC的实现看,触发条件是:

  • Young GC:新生代的eden区分配满的时候触发,把eden区存货的对象复制到一个Survivor区,当这个Survivor区满时,存活的对象被复制到另一个survivor区。
  • Full GC
    • 当准备要触发一次 Young GC 时,如果发现之前 Young GC 的平均晋升大小比目前 Old Gen剩余的空间大,则不会触发 Young GC 而是转为触发 Full GC
    • 如果有 Perm Gen 的话,要在 Perm Gen分配空间但已经没有足够空间时
    • System.gc()
    • Heap dump

并发 GC 的触发条件就不太一样。以 CMS GC 为例,它主要是定时去检查 Old Gen 的使用量,当使用量超过了触发比例就会启动一次 GC,对 Old Gen做并发收集。

2、堆外内存

DirectByteBuffer的引用是直接分配在堆的old区,回收时机是在FULLGC,因此需要避免频繁的分配directByteBuffer,这样就很容易导致Native Memory溢出。

DirectByteBuffer申请的直接内存,不在G范围内,无法自动回收。JDK提供一种机制,可以为堆内存对象注册一个钩子函数(其实就是实现 Runnable 接口的子类),当堆内存对象被GC回收的时候,会回调run方法,我们可以在这个方法中执行释放 DirectByteBuffer 引用的直接内存,即在run方法中调用 UnsafefreeMemory 方法。注册是通过sun.misc.Cleaner 类来实现的。

垃圾收集器

内存回收的具体实现,以下为7种不同分代的收集器,有连线表示可以搭配使用。

java虚拟机-第 3 页

1、Serial 收集器

最基本的收集器,单线程。是JVM在client模式下的默认新生代收集器。优点是:简单高效。此收集器没有线程交互的开销,效率高。在用户桌面的场景下,分配给JVM的内存不会太多,停顿时间在几十到一百多毫秒之间,只要不频繁收集,完全可以接受。

2、Serial Old收集器

Serial的老年代版本,单线程,采用“标记-整理算法”进行垃圾回收。

3、ParNew 收集器

Serial的多线程版本,是许多运行在Server模式下的默认新生代GC,主要与CMS收集器配合工作。

4、Parallel Scavenge 收集器

新生代GC,多线程收集器。更关注可控制的吞吐量,吞吐量等于运行用户代码的时间/(运行用户代码的时间+垃圾收集时间)。

5、Parallel Old

是Parallel Scavenge的老年代版本,多线程,通常与Parallel Scavenge配合使用。

6、CMS 收集器

目标是获取最短停顿时间,采用“标记-清除”算法,运行在老年代,包含以下步骤:

  • 初始标记
  • 并发标记
  • 重新标记
  • 并发清除

其中初始标记和重新标记仍然需要stop the world。初始标记仅仅标记GC ROOT能直接关联的对象,并发标记是为了记性GC ROOT Tracing过程,重新标记是为了修正并发标记期间,因为用户程序继续运行而导致标记变动的那部分对象的标记记录。

最耗时的是并发表标记和并发清除,收集线程和用户线程一起工作,总体来说,CMS GC回收和用户程序并行。优点是并发收集、低停顿,但是有三个缺点:

  • 对CPU资源很敏感:并发阶段不停用用户进程,但是占用线程导致程序变慢。
  • 不能处理浮动垃圾:浮动垃圾就是在并发标记阶段,用户程序运行产生新的垃圾,这部分垃圾在标记后,CMS无法在当次集中处理,只能在下一次GC处理,这部分垃圾就称为浮动垃圾。

正是由于在垃圾收集阶段程序还需要运行,即还需要预留足够的内存空间供用户使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎填满才进行收集,需要预留一部分空间提供并发收集时程序运作使用。要是 CMS 预留的内存空间不能满足程序的要求,这是 JVM 就会启动预备方案:临时启动 Serial Old 收集器来收集老年代,这样停顿的时间就会很长。

6、G1收集器

比GMS有很大改进:

  • 标记整理算法:采用标记整理算法实现
  • 增量回收模式:将Heap分割成多个Region,并在后台维护一个优先列表,每次根据允许的时间,优先回收垃圾最多的区域。

因此G1收集器可以实现在基本不牺牲吞吐量的情况下完成低停顿的内存回收,这是正式由于他极力的避免全区域回收。

总结

垃圾收集器 特性 算法 优点 缺点
Serial 串行 复制 高效:无线程切换 无法利用多核CPU
ParNew 并行 复制 可利用多核CPU、唯一能与CMS配合的并行收集器
Parallel Scavenge 并行 复制 高吞吐量
Serial Old 串行 标记整理 高效 无法利用多核CPU
Parallel Old 并行 标记整理 高吞吐量
CMS 并行 标记清除 低停顿 CPU敏感,浮动垃圾,内存碎片
G1 并行 增量回收 低停顿。高吞吐量 内存使用效率低:分区导致内存不能充分使用

5、Java分配机制

java中符合“编译时可知,运行时不可变”要求的主要是静态方法和私有方法。因此适合在类加载时进行解析。

java虚拟机中有四种方法调用指令:

  • invokestatic:调用静态方法。
  • invokespecial:调用实例构造方法,私有方法和super
  • invokeinterface:调用接口方法
  • invokevirtual:调用以上指令不能调用的方法(虚方法)。

只要能被static和special指令调用的方法,都可以在解析阶段唯一确定调用版本,符合条件的有:静态方法,私有方法,实例构造器,父类方法,他们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法称为非虚方法, 反之称为虚方法,(final除外)。

虽然final方法是使用virtual指令来调用,但是无法被覆盖,多态的选择是唯一的,所以是一种虚方法。

静态分派

对于类字段的访问也采用静态分派

People man = new Man()

静态分派主要针对重载,方法调用时如何选择。在上面的代码,Peopel被称为变量的引用类型,Man称为变量的实际类型。静态类型在编译时可知,动态类型在运行时可知。

编译器在重载时候通过参数的静态类型而不是实际类型作为判断依据。并且静态类型咋编译时可知,所以编译器根据重载的参数的静态类型进行方法选择。

在某些情况下有多个重载,那编译器如何选择呢? 编译器会选择”最合适”的函数版本,那么怎么判断”最合适“呢?越接近传入参数的类型,越容易被调用。

动态分派

主要针对重写,使用virtual指令调用,virtual指令多态查找过程:

  • 找到操作数栈顶的第一个元素所指向的对象的实际类型,记为C
  • 如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果权限校验不通过,返回java.lang.IllegalAccessError异常。
  • 否则,按照继承关系从下往上一次对C的各个父类进行第2步的搜索和验证过程。
  • 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError异常。

虚拟机动态分派的实现

动态分配很繁琐,而且动态分派的方法版本的选择需要考虑运行时咋类的方法数据中搜索合适的目标方法,因为在虚拟机的实现中基于性能的考虑,在方法区中建立一个虚方法表来提高性能。

image-20210324125440920

虚方法表中存放各个方法的实际入口地址。如果某个方法在子类中没有重写,那么子类的虚方法表里的入口和父类入口一致,如果子类重写了这个方法,那么子类方法表中的地址会被替换成子类实现版本的入口地址。

6、String常量池

常量池类似于一个JAVA系统级别提供的缓存。

String类型的常量池比较特殊,使用方法:

  • 直接使用双引号声明的String对象会直接存储在常量池。
  • 不是使用双引号,可以使用String提供的inter方法,该方法会从字符串常量池中查询当前字符串是否存在,不存在则放入常量池。

Intern

java使用Jni调用C++实现的StringTable的Intern方法,StringTable跟java中的HashMap实现差不多,但是不能扩容。默认大小是1009

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

JDK6中StringTable固定,但是JDK7可以通过参数指定:

1
-XX:StringTableSize=99991

在6和以前的版本,字符串的常量池存放在Perm区,7的版本中,转移到正常的Heap区

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

String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4);
}

执行结果:

  • JDK6:false false
  • JDK7:fale true
1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
String s = new String("1");
String s2 = "1";
s.intern();
System.out.println(s == s2);

String s3 = new String("1") + new String("1");
String s4 = "11";
s3.intern();
System.out.println(s3 == s4);
}

执行结果:

  • 6:false false
  • 7:false false

由于7将字符串常量池放到Heap中,导致差异。

JDK 6

1

黑色线表示String对象的内容指向,红色代表地址指向。

在jdk6中所有打印都是false,因为常量池在Perm中,Perm和正常的Heap是分开的,引号声明的字符串直接在常量池生成,而new出来的String存放在Heap,地址肯定不同,及时调用String.intern()方法也是没有很合关系。

JDK 7

java虚拟机-第 5 页

  • 第一段代码,String s3 = new String("1")+new String("1");,代码生成两个最终对象,是常量池中的“1”和Heap中S3引用指向的对象。此时S3引用的对象是“11”,但是常量池没有“11”对象。
  • 接下来S3.intern()是讲S3中的“11”放入常量池。但是7中的常量池不在Perm区域了,常量池中不需要再存储一份对象,可以直接存储占中的引用。这份引用指向S3的对象。引用地址是相同的。
  • 最后 String s4 = "11"; 这句代码中 ”11” 是显示声明的,因此会直接去常量池中创建,创建的时候发现已经有这个对象了,此时也就是指向 s3 引用对象的一个引用。所以 s4 引用就指向和 s3 一样了。因此最后的比较 s3 == s4true
  • 再看 ss2 对象。 String s = new String("1"); 第一句代码,生成了2个对象。常量池中的 “1”JAVA Heap 中的字符串对象。s.intern(); 这一句是 s 对象去常量池中寻找后发现 “1” 已经在常量池里了。
  • 接下来 String s2 = "1"; 这句代码是生成一个 s2 的引用指向常量池中的 “1” 对象。 结果就是 ss2 的引用地址明显不同。

接下来是第二段代码:

java虚拟机-第 6 页

  • 第一段代码和第二段代码的改变就是 s3.intern(); 的顺序是放在 String s4 = "11"; 后了。这样,首先执行 String s4 = "11"; 声明 s4 的时候常量池中是不存在 “11” 对象的,执行完毕后, “11“ 对象是 s4 声明产生的新对象。然后再执行 s3.intern(); 时,常量池中 “11” 对象已经存在了,因此 s3s4 的引用是不同的。
  • 二段代码中的 ss2 代码中,s.intern();,这一句往后放也不会有什么影响了,因为对象池中在执行第一句代码String s = new String("1"); 的时候已经生成 “1” 对象了。下边的 s2 声明都是直接从常量池中取地址引用的。 ss2 的引用地址是不会相等的。

小节

对intern和常量池都做了一定的修改,主要包括:

  • 将String常量池从Perm区移动到了Heap区
  • String.intern方法时,如果heap中存在对象,将会直接保存对象的引用,而不是重新创建对象。

使用范例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static final int MAX = 1000 * 10000;
static final String[] arr = new String[MAX];

public static void main(String[] args) throws Exception {
Integer[] DB_DATA = new Integer[10];
Random random = new Random(10 * 10000);
for (int i = 0; i < DB_DATA.length; i++) {
DB_DATA[i] = random.nextInt();
}
long t = System.currentTimeMillis();
for (int i = 0; i < MAX; i++) {
//arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length]));
arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern();
}

System.out.println((System.currentTimeMillis() - t) + "ms");
System.gc();
}

一条是使用 intern,一条是未使用 intern。

通过上述结果,我们发现不使用 intern 的代码生成了 1000w 个字符串,占用了大约 640m 空间。 使用了 intern 的代码生成了 1345 个字符串,占用总空间 133k 左右。其实通过观察程序中只是用到了 10 个字符串,所以准确计算后应该是正好相差 100w 倍。虽然例子有些极端,但确实能准确反应出 intern 使用后产生的巨大空间节省。

intern 方法后时间上有了一些增长。这是因为程序中每次都是用了 new String 后,然后又进行 intern 操作的耗时时间,这一点如果在内存空间充足的情况下确实是无法避免的,但我们平时使用时,内存空间肯定不是无限大的,不使用 intern 占用空间导致 jvm 垃圾回收的时间是要远远大于这点时间的。

7、对象生命周期

类实例化

实例化类的四个途径:

  • new
  • 调用class或者java.lang.reflect.Constructor对象的NewInstance
  • 调用任何现有对象的clone
  • 通过java.io.ObjectInputStream.getObject()反序列化。

隐含的实例化:

  • 保存命令行参数的String对象
  • 虚拟机装载的每个类,都会暗中实例化一个class对象来代表这个类型。
  • 当Java虚拟机装载了在常量池中包含CONSTANT_String_info入口的类的时候,它会创建新的String对象来表示这些常量字符串。
  • 执行包含字符串连接操作符的表达式会产生新的对象。

垃圾收集和对象的终结

程序可以分配内存但不能释放内存,一个对象不再为程序引用,虚拟机必须回收那部分内存。

卸载类

虚拟机中类的生命周期和对象的生命周期相似。当程序不再使用某个类的时候,可以选择卸载他们。

虚拟机通过判断类是否在被引用来实现垃圾收集。判断动态装载类的Class实例在正常的垃圾收集过程中是否可触及有两种方式:

  • 如果实例非Class实例的明确引用。
  • 如果在堆中还存在一个可触及对象,在方法区中它的类型数据指向一个Class实例。

image-20210324173320715

  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.

请我喝杯咖啡吧~

支付宝
微信