网络知识 娱乐 别动 我把知识装你脑子里 冷宫霸主JVM

别动 我把知识装你脑子里 冷宫霸主JVM

前言

JVM 真的是学完忘。忘了学 因为很少去用 工作中很少接触 但是又是一个必须了解的都东西 复习整理必不可少

JVM 架构

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

JVM 的基本架构如上图所示,其主要包含三个大块:

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

线程

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

主要的系统线程有:

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

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

  • 守护线程:通常是由虚拟机自己使用,比如 GC 线程。但是,Java程序也可以把它自己创建的任何线程标记为守护线程(public final void setDaemon(boolean on)来设置,但必须在start()方法之前调用)。
  • 非守护线程:main方法执行的线程,我们通常也称为用户线程。

只要有任何的非守护线程在运行,Java程序也会继续运行。当该程序中所有的非守护线程都终止时,虚拟机实例将自动退出(守护线程随 JVM 一同结束工作)。

守护线程中不适合进行IO、计算等操作,因为守护线程是在所有的非守护线程退出后结束,这样并不能判断守护线程是否完成了相应的操作,如果非守护线程退出后,还有大量的数据没来得及读写,这将造成很严重的后果。

类加载器

类加载器是 Java 运行时环境(Java Runtime Environment)的一部分,负责动态加载 Java 类到 Java 虚拟机的内存空间中。类通常是按需加载,即第一次使用该类时才加载。 由于有了类加载器,Java 运行时系统不需要知道文件与文件系统。每个 Java 类必须由某个类加载器装入到内存。

类装载器除了要定位和导入二进制 class 文件外,还必须负责验证被导入类的正确性,为变量分配初始化内存,以及帮助解析符号引用。这些动作必须严格按一下顺序完成:

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

装载

类加载器分类

在Java虚拟机中存在多个类装载器,Java应用程序可以使用两种类装载器:

  • Bootstrap ClassLoader:此装载器是 Java 虚拟机实现的一部分。由原生代码(如C语言)编写,不继承自 java.lang.ClassLoader 。负责加载核心 Java 库,启动类装载器通常使用某种默认的方式从本地磁盘中加载类,包括 Java API。
  • Extention Classloader:用来在<JAVA_HOME>/jre/lib/ext ,或 java.ext.dirs 中指明的目录中加载 Java 的扩展库。 Java 虚拟机的实现会提供一个扩展库目录。
  • Application Classloader:根据 Java应用程序的类路径( java.class.path 或 CLASSPATH 环境变量)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader() 来获取它。
  • 自定义类加载器:可以通过集成 java.lang.ClassLoader 类的方式实现自己的类加载器,以满足一些特殊的需求而不需要完全了解 Java 虚拟机的类加载的细节。

全盘负责双亲委托机制

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

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

全盘负责双亲委托机制只是 Java 推荐的机制,并不是强制的机制。实现自己的类加载器时,如果想保持双亲委派模型,就应该重写 findClass(name) 方法;如果想破坏双亲委派模型,可以重写 loadClass(name) 方法。

装载入口

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

  • 当创建某个类的新实例时(new、反射、克隆、序列化)
  • 调用某个类的静态方法
  • 使用某个类或接口的静态字段,或对该字段赋值(用final修饰的静态字段除外,它被初始化为一个编译时常量表达式)
  • 当调用Java API的某些反射方法时。
  • 初始化某个类的子类时。
  • 当虚拟机启动时被标明为启动类的类。

除以上六种情况,所有其他使用Java类型的方式都是被动的,它们不会导致Java类型的初始化。

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

父类需要在子类初始化之前被初始化。当实现了接口的类被初始化的时候,不需要初始化父接口。然而,当实现了父接口的子类(或者是扩展了父接口的子接口)被装载时,父接口也要被装载。(只是被装载,没有初始化)

验证

确认装载后的类型符合Java语言的语义,并且不会危及虚拟机的完整性。

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

准备

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

类型

默认值

int

0

long

0L

short

(short)0

char

‘u0000’

byte

(byte)0

blooean

false

float

0.0f

double

0.0d

reference

null

解析

解析的过程就是在类型的常量池总寻找类、接口、字段和方法的符号引用,把这些符号引用替换为直接引用的过程

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

初始化

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

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

这个步骤是递归执行的,即第一个初始化的类一定是Object。

Java虚拟机必须确保初始化过程被正确地同步。 如果多个线程需要初始化一个类,仅仅允许一个线程来进行初始化,其他线程需等待。

这个特性可以用来写单例模式。

Clinit 方法

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

运行时数据区

运行时数据区用于保存 JVM 在运行过程中产生的数据,结构如图所示:

Heap

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

Heap 中除了作为对象分配使用,还包含字符串字面量 常量池(Internd Strings) 。 除此之外 Heap 中还包含一个 新生代(Yong Generation) 、一个 老年代(Old Generation)

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

老年代中保存一些生命周期较长的对象,当一个对象经过多次的 GC 后还没有被回收,那么它将被移动到老年代。

Methoad Area

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

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

  • ClassLoader Reference
  • Runtime Constant Pool
    • 数字常量
    • 类属性引用
    • 方法引用
  • Field Data:每个类属性的名称、类型等
  • Methoad Data:每个方法的名称、返回值类型、参数列表等
  • Methoad Code:每个方法的字节码、本地变量表等

方法区的实现在不同的 JVM 版本有不同,在 JVM 1.8 之前,方法区的实现为 永久代(PermGen) ,但是由于永久代的大小限制, 经常会出现内存溢出。于是在 JVM 1.8 方法区的实现改为 元空间(Metaspace) ,元空间是在 Native 的一块内存空间。

Stack

对于每个 JVM 线程,当线程启动时,都会分配一个独立的运行时栈,用以保存方法调用。每个方法调用,都会在栈顶增加一个栈帧(Stack Frame)。

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

当被调用的方法返回或抛出异常,栈帧会被弹出。在抛出异常时 printStackTrace() 打印的每一行就是一个栈帧。同时得益于栈帧的特点,栈帧内的数据是线程安全的。

栈的大小可以动态扩展,但是如果一个线程需要的栈大小超过了允许的大小,就会抛出 StackOverflowError。

PC Register

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

Native Method Stack

本地方法栈和 Java 虚拟机栈的作用相似,Java 虚拟机栈执行的是字节码,而本地方法栈执行的是 native 方法。本地方法栈使用传统的栈(C Stack)来支持 native 方法。

Direct Memory

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

垃圾回收

对象存活检测

Java堆中存放着大量的Java对象实例,在垃圾收集器回收内存前,第一件事情就是确定哪些对象是活着的,哪些是可以回收的。

引用计数算法

引用计数算法是判断对象是否存活的基本算法:给每个对象添加一个引用计数器,没当一个地方引用它的时候,计数器值加1;当引用失效后,计数器值减1。但是这种方法有一个致命的缺陷,当两个对象相互引用时会导致这两个都无法被回收

根搜索算法

引用计数是通过为堆中每个对象保存一个计数来区分活动对象和垃圾。根搜索算法实际上是追踪从根结点开始的 引用图

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

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

根搜索算法是从 GC Root 开始的引用图,引用图是一个有向图,其中节点是各个对象,边为引用类型。JVM 中的引用类型分为四种:强引用(StrongReference)软引用(SoftReference)弱引用(WeakReference)虚引用(PhantomReference)

除强引用外,其他引用在Java 由 Reference 的子类封装了指向其他对象的连接:被指向的对象称为 引用目标

若一个对象的引用类型有多个,那到底如何判断它的回收策略呢?其实规则如下:

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

在引用图中,当一个节点没有任何路径可达时,我们认为它是可回收的对象。

StrongReference

强引用在Java中是普遍存在的,类似 Object o = new Object(); 。强引用和其他引用的区别在于:强引用禁止引用目标被垃圾收集器收集,而其他引用不禁止

SoftReference

对象可以从根节点通过一个或多个(未被清除的)软引用对象触及,垃圾收集器在要发生内存溢出前将这些对象列入回收范围中进行回收,如果该软引用对象和引用队列相关联,它会把该软引用对象加入队列。

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

WeakReference

对象可以从 GC Root 开始通过一个或多个(未被清除的)弱引用对象触及, 垃圾收集器在 GC 的时候会回收所有的 WeakReference,如果该弱引用对象和引用队列相关联,它会把该弱引用对象加入队列。

PhantomReference

垃圾收集器在 GC 不会清除 PhantomReference,所有的虚引用都必须由程序明确的清除。同时也不能通过虚引用来取得一个对象的实例。

垃圾回收算法

复制回收算法

将可用内存分为大小相等的两份,在同一时刻只使用其中的一份。当这一份内存使用完了,就将还存活的对象复制到另一份上,然后将这一份上的内存清空。复制算法能有效避免内存碎片,但是算法需要将内存一分为二,导致内存使用率大大降低。

标记清除算法

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

标记整理算法

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

增量回收

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

分代回收

在 JVM 中不同的对象拥有不同的生命周期,因此对于不同生命周期的对象也可以采用不同的垃圾回收算法,以提高效率,这就是分代回收算法的核心思想。

记忆集

上面有说到进行 GC 的时候,会从 GC Root 进行搜索,做一个引用图。现有一个对象 C 在 Young Gen,其只被一个在 Old Gen 的对象 D 引用,其引用结构如下所示:

这个时候要进行 Young GC,要确定 C 是否被堆外引用,就需要遍历 Old Gen,这样的代价太大。所以 JVM 在进行对象引用的时候,会有个 记忆集(Remembered Set) 记录从 Old Gen 到 Young Gen 的引用关系,并把记忆集里的 Old Gen 作为 GC Root 来构建引用图。这样在进行 Young GC 时就不需要遍历 Old Gen。

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

垃圾回收触发条件

堆内内存

针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:

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

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

  • Young GC:当 Young Gen 中的 eden 区分配满的时候触发。把 Eden 区存活的对象将被复制到一个 Survivor 区,当这个 Survivor 区满时,此区的存活对象将被复制到另外一个 Survivor 区。
  • Full GC
    • 当准备要触发一次 Young GC 时,如果发现之前 Young GC 的平均晋升大小比目前 Old Gen剩余的空间大,则不会触发 Young GC 而是转为触发 Full GC
    • 除了 CMS 的 Concurrent Collection 之外,其它能收集 Old Gen 的GC都会同时收集整个 GC 堆,包括 Young Gen,所以不需要事先触发一次单独的Young GC
    • 如果有 Perm Gen 的话,要在 Perm Gen分配空间但已经没有足够空间时
    • System.gc()
    • Heap dump

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

堆外内存

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

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

垃圾收集器

垃圾收集器是内存回收的具体实现,下图展示了 7 种用于不同分代的收集器,两个收集器之间有连线表示可以搭配使用,每种收集器都有最适合的使用场景。

Serial 收集器

Serial 收集器是最基本的收集器,这是一个单线程收集器,它只用一个线程去完成垃圾收集工作。

虽然 Serial 收集器的缺点很明显,但是它仍然是 JVM 在 Client 模式下的默认新生代收集器。它有着优于其他收集器的地方:简单而高效(与其他收集器的单线程比较),Serial 收集器由于没有线程交互的开销,专心只做垃圾收集自然也获得最高的效率。在用户桌面场景下,分配给 JVM 的内存不会太多,停顿时间完全可以在几十到一百多毫秒之间,只要收集不频繁,这是完全可以接受的。

ParNew 收集器

ParNew 是 Serial 的多线程版本,在回收算法、对象分配原则上都是一致的。ParNew 收集器是许多运行在Server 模式下的默认新生代垃圾收集器,其主要与 CMS 收集器配合工作。

Parallel Scavenge 收集器

Parallel Scavenge 收集器是一个新生代垃圾收集器,也是并行的多线程收集器。

Parallel Scavenge 收集器更关注可控制的吞吐量,吞吐量等于运行用户代码的时间/(运行用户代码的时间+垃圾收集时间)。

Serial Old收集器

Serial Old 收集器是 Serial 收集器的老年代版本,也是一个单线程收集器,采用“标记-整理算法”进行回收。

Parallel Old 收集器

Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,使用多线程进行垃圾回收,其通常与 Parallel Scavenge 收集器配合使用。

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短停顿时间为目标的收集器, CMS 收集器采用 标记--清除 算法,运行在老年代。主要包含以下几个步骤:

  • 初始标记(Stop the world)
  • 并发标记
  • 重新标记(Stop the world)
  • 并发清除

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

由于整个过程中最耗时的并发标记和并发清除,收集线程和用户线程一起工作,所以总体上来说, CMS 收集器回收过程是与用户线程并发执行的。虽然 CMS 优点是并发收集、低停顿,很大程度上已经是一个不错的垃圾收集器,但是还是有三个显著的缺点:

  • CMS收集器对CPU资源很敏感:在并发阶段,虽然它不会导致用户线程停顿,但是会因为占用一部分线程(CPU资源)而导致应用程序变慢。
  • CMS收集器不能处理浮动垃圾:所谓的“浮动垃圾”,就是在并发标记阶段,由于用户程序在运行,那么自然就会有新的垃圾产生,这部分垃圾被标记过后,CMS 无法在当次集中处理它们,只好在下一次 GC 的时候处理,这部分未处理的垃圾就称为“浮动垃圾”。
  • GC 后产生大量内存碎片:当内存碎片过多时,将会给分配大对象带来困难,这是就会进行 Full GC。

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

G1收集器

G1收集器与CMS相比有很大的改进:

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

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

Java分派机制

在Java中,符合“编译时可知,运行时不可变”这个要求的方法主要是静态方法和私有方法。这两种方法都不能通过继承或别的方法重写,因此它们适合在类加载时进行解析。

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

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

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

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

静态分派

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

People man = new Man()

静态分派主要针对重载,方法调用时如何选择。在上面的代码中,People被称为变量的引用类型,Man被称为变量的实际类型。静态类型是在编译时可知的,而动态类型是在运行时可知的,编译器不能知道一个变量的实际类型是什么。

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

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

动态分派

动态分派主要针对重写,使用invokevirtual指令调用。invokevirtual指令多态查找过程:

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

虚拟机动态分派的实现

由于动态分派是非常繁琐的动作,而且动态分派的方法版本选择需要考虑运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实现中基于性能的考虑,在方法区中建立一个虚方法表(invokeinterface 有接口方法表),来提高性能。

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

String 常量池

在 JAVA 语言中有 8 中基本类型和一种比较特殊的类型 String 。这些类型为了使他们在运行过程中速度更快,更节省内存,都提供了一种常量池的概念。常量池就类似一个 JAVA 系统级别提供的缓存。

String 类型的常量池比较特殊。它的主要使用方法有两种:

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

intern

/**n * Returns a canonical representation for the string object.n * <p>n * A pool of strings, initially empty, is maintained privately by then * class {@code String}.n * <p>n * When the intern method is invoked, if the pool already contains an * string equal to this {@code String} object as determined byn * the {@link #equals(Object)} method, then the string from the pool isn * returned. Otherwise, this {@code String} object is added to then * pool and a reference to this {@code String} object is returned.n * <p>n * It follows that for any two strings {@code s} and {@code t},n * {@code s.intern() == t.intern()} is {@code true}n * if and only if {@code s.equals(t)} is {@code true}.n * <p>n * All literal strings and string-valued constant expressions aren * interned. String literals are defined in section 3.10.5 of then * <cite>The Java™ Language Specification</cite>.n *n * @return a string that has the same contents as this string, but isn * guaranteed to be from a pool of unique strings.n */n public native String intern();

JAVA 使用 jni 调用 c++ 实现的 StringTable 的 intern 方法, StringTable 跟 Java 中的 HashMap 的实现是差不多的, 只是 不能自动扩容。默认大小是 1009 。

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

在 JDK6 中 StringTable 是固定的,就是 1009 的长度,所以如果常量池中的字符串过多就会导致效率下降很快。在 jdk7 中, StringTable 的长度可以通过一个参数指定:

-XX:StringTableSize=99991

在 JDK6 以及以前的版本中,字符串的常量池是放在堆的 Perm 区。在 JDK7 的版本中,字符串常量池已经从 Perm 区移到正常的 Java Heap 区域

public static void main(String[] args) {n String s = new String("1");n s.intern();n String s2 = "1";n System.out.println(s == s2);nn String s3 = new String("1") + new String("1");n s3.intern();n String s4 = "11";n System.out.println(s3 == s4);n}

上述代码的执行结果:

  • JDK6: false false
  • JDK7: false true

public static void main(String[] args) {n String s = new String("1");n String s2 = "1";n s.intern();n System.out.println(s == s2);nn String s3 = new String("1") + new String("1");n String s4 = "11";n s3.intern();n System.out.println(s3 == s4);n}

上述代码的执行结果:

  • JDK6: false false
  • JDK7: false false

由于 JDK7 将字符串常量池移动到 Heap 中,导致上述版本差异,下面具体来分析下。

JDK6

图中绿色线条代表 string 对象的内容指向,黑色线条代表地址指向

在 jdk6 中上述的所有打印都是 false ,因为 jdk6 中的常量池是放在 Perm 区中的, Perm 区和正常的 JAVA Heap 区域是完全分开的。上面说过如果是使用引号声明的字符串都是会直接在字符串常量池中生成,而 new 出来的 String 对象是放在 JAVA Heap 区域。所以拿一个 JAVA Heap 区域的对象地址和字符串常量池的对象地址进行比较肯定是不相同的,即使调用 String.intern 方法也是没有任何关系的

JDK7

因为字符串常量池移动到 JAVA Heap 区域后,再来解释为什么会有上述的打印结果。

  • 在第一段代码中,先看 s3 和 s4 字符串。String s3 = new String("1") + new String("1");,这句代码中现在生成了 2个 最终对象,是字符串常量池中的 “1” 和 JAVA Heap 中的 s3 引用指向的对象。中间还有 2个 匿名的 new String("1") 我们不去讨论它们。此时 s3 引用对象内容是 ”11” ,但此时常量池中是没有 “11” 对象的。
  • 接下来 s3.intern(); 这一句代码,是将 s3 中的 “11” 字符串放入 String 常量池中,因为此时常量池中不存在 “11” 字符串,因此常规做法是跟 jdk6 图中表示的那样,在常量池中生成一个 “11” 的对象,关键点是 jdk7 中常量池不在 Perm 区域了,这块做了调整。常量池中不需要再存储一份对象,可以直接存储堆中的引用。这份引用指向 s3 引用的对象。 也就是说引用地址是相同的。
  • 最后 String s4 = "11"; 这句代码中 ”11” 是显示声明的,因此会直接去常量池中创建,创建的时候发现已经有这个对象了,此时也就是指向 s3 引用对象的一个引用。所以 s4 引用就指向和 s3 一样了。因此最后的比较 s3 == s4 是 true 。
  • 再看 s 和 s2 对象。 String s = new String("1"); 第一句代码,生成了2个对象。常量池中的 “1” 和 JAVA Heap 中的字符串对象。s.intern(); 这一句是 s 对象去常量池中寻找后发现 “1” 已经在常量池里了。
  • 接下来 String s2 = "1"; 这句代码是生成一个 s2 的引用指向常量池中的 “1” 对象。 结果就是 s 和 s2 的引用地址明显不同。

接下来是第二段代码:

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

小结

从上述的例子代码可以看出 jdk7 版本对 intern 操作和常量池都做了一定的修改。主要包括2点:

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

使用范例

static final int MAX = 1000 * 10000;nstatic final String[] arr = new String[MAX];nnpublic static void main(String[] args) throws Exception {n Integer[] DB_DATA = new Integer[10];n Random random = new Random(10 * 10000);n for (int i = 0; i < DB_DATA.length; i++) {n DB_DATA[i] = random.nextInt();n }ntlong t = System.currentTimeMillis();n for (int i = 0; i < MAX; i++) {n //arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length]));n arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern();n }nntSystem.out.println((System.currentTimeMillis() - t) + "ms");n System.gc();n}

运行的参数是:-Xmx2g -Xms2g -Xmn1500M 上述代码是一个演示代码,其中有两条语句不一样,一条是使用 intern,一条是未使用 intern。

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

细心的同学会发现使用了 intern 方法后时间上有了一些增长。这是因为程序中每次都是用了 new String 后,然后又进行 intern 操作的耗时时间,这一点如果在内存空间充足的情况下确实是无法避免的,但我们平时使用时,内存空间肯定不是无限大的,不使用 intern 占用空间导致 jvm 垃圾回收的时间是要远远大于这点时间的。 毕竟这里使用了 1000w 次 intern 才多出来1秒钟多的时间。

不当使用

fastjson 中对所有的 json 的 key 使用了 intern 方法,缓存到了字符串常量池中,这样每次读取的时候就会非常快,大大减少时间和空间。而且 json 的 key 通常都是不变的。这个地方没有考虑到大量的 json key 如果是变化的,那就会给字符串常量池带来很大的负担。

这个问题 fastjson 在1.1.24版本中已经将这个漏洞修复了。程序加入了一个最大的缓存大小,超过这个大小后就不会再往字符串常量池中放了。

对象的生命周期

一旦一个类被装载、连接和初始化,它就随时可以被使用。程序可以访问它的静态字段,调用它的静态方法,或者创建它的实例。作为Java程序员有必要了解Java对象的生命周期。

类实例化

在Java程序中,类可以被明确或隐含地实例化。明确的实例化类有四种途径:

  • 明确调用new。
  • 调用Class或者java.lang.reflect.Constructor对象的newInstance方法。
  • 调用任何现有对象的clone。
  • 通过java.io.ObjectInputStream.getObject()反序列化。

隐含的实例化:

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

Java编译器为它编译的每个类至少生成一个实例初始化方法。在Java class文件中,这个方法被称为<init>。针对源代码中每个类的构造方法,Java编译器都会产生一个<init>()方法。如果类没有明确的声明任何构造方法,编译器会默认产生一个无参数的构造方法,它仅仅调用父类的无参构造方法。

一个<init>()中可能包含三种代码:调用另一个<init>()、实现对任何实例变量的初始化、构造方法体的代码。

如果构造方法明确的调用了同一个类中的另一个构造方法(this()),那么它对应的<init>()由两部分组成:

  • 一个同类的<init>()的调用。
  • 实现了对应构造方法的方法体的字节码。

在它对应的<init>()方法中不会有父类的<init>(),但不代表不会调用父类的<init>(),因为this()中也会调用父类<init>()

如果构造方法不是通过一个this()调用开始的,而且这个对象不是Object,<init>()则有三部分组成:

  • 一个父类的<init>()调用。如果这个类是Object,则没有这个部分
  • 任意实例变量初始化方法的字节码。
  • 实现了对应构造方法的方法体的字节码。

如果构造方法明确的调用父类的构造方法super()开始,它的<init>()会调用对应父类的<init>()。比如,如果一个构造方法明确的调用super(int,String)开始,对应的<init>()会从调用父类的<init>(int,String)方法开始。如果构造方法没有明确地从this()或super()开始,对应的<init>()默认会调用父类的无参<init>()。

垃圾收集和对象的终结

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

卸载类

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

类的垃圾收集和卸载值所以在Java虚拟机中很重要,是因为Java程序可以在运行时通过用户自定义的类装载器装载类型来动态的扩展程序。所有被装载的类型都在方法区占据内存空间。

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

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