深入理解java虚拟机

深入理解java虚拟机

  • (一)、Java运行时数据区域和Java内存模型(JMM)

  • 程序计数器

  • Java虚拟机栈

      1. 局部变量表
      1. 操作栈
      1. 动态链接
    • 4.方法返回地址(return Address)
  • 本地方法栈

  • Java堆

  • 方法区

  • 运行时常量池

    • 直接内存
    • Java内存模型
  • JVM主内存与工作内存 * 重排序和happens-before规则 * volatile关键字

  • (二)、Java垃圾收集和内存分配策略

  • 如何确定某个对象是“垃圾”?

  • 典型的垃圾收集算法

    • 1.Mark-Sweep(标记-清除)算法
    • 2.Copying(复制)算法
    • 3.Mark-Compact(标记-整理)算法(压缩法)
    • 4.Generational Collection(分代收集)算法
  • 典型的垃圾收集器

  • 内存分配与回收策略

    • (1)优先分配到Eden
    • (2)大对象直接分配到老年代
    • (3)长期存活的对象分配到老年代
    • (4)空间分配担保
    • (5)动态对象的年龄判定
    • (6)逃逸分析和栈上分配
  • (三)、Java类文件结构

  • 魔数(magic)与class文件的版本

  • 常量池(constant_pool_count(u2)与constantpool (cp_info) )

  • 访问标志

  • 类索引、父类索引、接口索引集合

  • 字段表集合

  • 方法表集合

  • 属性表集合

  • (四)、Java类加载机制

  • 加载

  • 连接

  • 初始化

    • 1.Java9之前

      • 类加载器
      • 类加载机制
    • 2.Java9的改变

      • 类加载器
      • 类加载机制

在这里插入图片描述

(一)、Java运行时数据区域和Java内存模型(JMM)

Java 内存区域和内存模型是不一样的东西,内存区域是指 Jvm 运行时将数据分区域存储,强调对内存空间的划分。

而内存模型(Java Memory Model,简称 JMM )是定义了线程和主内存之间的抽象关系,即 JMM 定义了 JVM 在计算机内存(RAM)中的工作方式,如果我们要想深入了解Java并发编程,就要先理解好Java内存模型。

众所周知,Java 虚拟机有自动内存管理机制,如果出现内存泄漏和溢出方面的问题,排查错误就必须要了解虚拟机是怎样使用内存的。

JDK8 之前的内存区域图如下:
在这里插入图片描述
下图是 JDK8 之后的 JVM 内存布局:

在这里插入图片描述

在 HotSpot JVM 中,永久代中用于存放类和方法的元数据以及常量池,比如Class和Method。每当一个类初次被加载的时候,它的元数据都会放到永久代中。
永久代是有大小限制的,因此如果加载的类太多,很有可能导致永久代内存溢出,即万恶的 java.lang.OutOfMemoryError: PermGen ,为此我们不得不对虚拟机做调优。
那么,Java 8 中 PermGen 为什么被移出 HotSpot JVM 了?我总结了两个主要原因:

  • 由于 PermGen 内存经常会溢出,引发恼人的 java.lang.OutOfMemoryError: PermGen,因此 JVM 的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的 OOM
  • 移除 PermGen 可以促进 HotSpot JVM 与 JRockit VM 的融合,因为 JRockit 没有永久代。

根据上面的各种原因,PermGen 最终被移除,方法区移至 Metaspace,字符串常量移至 Java Heap。

引用自https://www.sczyh30.com/posts/Java/jvm-metaspace/

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。

由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器内核都只会执行一条线程中的指令。

因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

Java虚拟机栈

与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。

虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame,是方法运行时的基础数据结构)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

在活动线程中,只有位千栈顶的帧才是有效的,称为当前栈帧。正在执行的方法称为当前方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。
在这里插入图片描述

1. 局部变量表

局部变量表是存放方法参数和局部变量的区域。 局部变量没有准备阶段, 必须显式初始化。如果是非静态方法,则在 index[0] 位置上存储的是方法所属对象的实例引用,一个引用变量占 4 个字节,随后存储的是参数和局部变量。字节码指令中的 STORE 指令就是将操作栈中计算完成的局部变呈写回局部变量表的存储空间内。

虚拟机栈规定了两种异常状况:
1、如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;
2、如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。

2. 操作栈

操作栈是个初始状态为空的桶式结构栈。在方法执行过程中, 会有各种指令往
栈中写入和提取信息。JVM 的执行引擎是基于栈的执行引擎, 其中的栈指的就是操
作栈。字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的 stack 属性中。
i++ 和 ++i 的区别:

  1. i++:从局部变量表取出 i 并压入操作栈,然后对局部变量表中的 i 自增 1,将操作栈栈顶值取出使用,最后,使用栈顶值更新局部变量表,如此线程从操作栈读到的是自增之前的值。
  2. ++i:先对局部变量表的 i 自增 1,然后取出并压入操作栈,再将操作栈栈顶值取出使用,最后,使用栈顶值更新局部变量表,线程从操作栈读到的是自增之后的值。

之前之所以说 i++ 不是原子操作,即使使用 volatile 修饰也不是线程安全,就是因为,可能 i 被从局部变量表(内存)取出,压入操作栈(寄存器),操作栈中自增,使用栈顶值更新局部变量表(寄存器更新写入内存),其中分为 3 步,volatile 保证可见性,保证每次从局部变量表读取的都是最新的值,但可能这 3 步可能被另一个线程的 3 步打断,产生数据互相覆盖问题,从而导致 i 的值比预期的小。

3. 动态链接

每个栈帧中包含一个在常量池中对当前方法的引用, 目的是支持方法调用过程的动态连接。

4.方法返回地址(return Address)

方法执行时有两种退出情况:

  1. 正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、IRETURN、ARETURN 等;
  2. 异常退出。

无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:

  1. 返回值压入上层调用栈帧。
  2. 异常信息抛给能够处理的栈帧。
  3. PC计数器指向方法调用后的下一条指令。

本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。Sun HotSpot 虚拟机直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

线程开始调用本地方法时,会进入 个不再受 JVM 约束的世界。本地方法可以通过 JNI(Java Native Interface)来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和 JVM 相同的能力和权限。 当大量本地方法出现时,势必会削弱 JVM 对系统的控制力,因为它的出错信息都比较黑盒。对内存不足的情况,本地方法栈还是会抛出 nativeheapOutOfMemory。

JNI 类本地方法最著名的应该是 System.currentTimeMillis() ,JNI使 Java 深度使用操作系统的特性功能,复用非 Java 代码。 但是在项目过程中, 如果大量使用其他语言来实现 JNI , 就会丧失跨平台特性。

Java堆

对于大多数应用来说,Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”(Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。

Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx 和 -Xms 控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。

方法区

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

Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。垃圾收集行为在这个区域是比较少出现的,其内存回收目标主要是针对常量池的回收和对类型的卸载。当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

JDK8 之前,Hotspot 中方法区的实现是永久代(Perm),JDK8 开始使用元空间(Metaspace),以前永久代所有内容的字符串常量移至堆内存,其他内容移至元空间,元空间直接在本地内存分配。

为什么要使用元空间取代永久代的实现?

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

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

一般来说,除了保存 Class 文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。

在 JDK 1.4 中新加入了 NIO,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

在这里插入图片描述

Java内存模型

Java内存模型是共享内存的并发模型,线程之间主要通过读-写共享变量(堆内存中的实例域,静态域和数组元素)来完成隐式通信。

Java 内存模型(JMM)控制 Java 线程之间的通信,决定一个线程对共享变量的写入何时对另一个线程可见。

计算机高速缓存和缓存一致性
计算机在高速的 CPU 和相对低速的存储设备之间使用高速缓存,作为内存和处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,让运算能快速运行,当运算结束后再从缓存同步回内存之中。

在多处理器的系统中(或者单处理器多核的系统),每个处理器内核都有自己的高速缓存,它们有共享同一主内存(Main Memory)。

当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。

为此,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,来维护缓存的一致性。

在这里插入图片描述

JVM主内存与工作内存

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量(线程共享的变量)存储到内存和从内存中取出变量这样底层细节。

Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

这里的工作内存是 JMM 的一个抽象概念,也叫本地内存,其存储了该线程以读 / 写共享变量的副本。

就像每个处理器内核拥有私有的高速缓存,JMM 中每个线程拥有私有的本地内存。

不同线程之间无法直接访问对方工作内存中的变量,线程间的通信一般有两种方式进行,一是通过消息传递,二是共享内存。Java 线程间的通信采用的是共享内存方式,线程、主内存和工作内存的交互关系如下图所示:
在这里插入图片描述

这里所讲的主内存、工作内存与 Java 内存区域中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。

重排序和happens-before规则

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
在这里插入图片描述
JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

java 编译器禁止处理器重排序是通过在生成指令序列的适当位置会插入内存屏障(重排序时不能把后面的指令重排序到内存屏障之前的位置)指令来实现的。

happens-before
从 JDK5 开始,java 内存模型提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。

如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。

如果 A happens-before B,那么 Java 内存模型将向程序员保证—— A 操作的结果将对 B 可见,且 A 的执行顺序排在 B 之前。

重要的 happens-before 规则如下:

  1. 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
  2. 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
  3. volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。
  4. 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。

下图是 happens-before 与 JMM 的关系:
在这里插入图片描述

volatile关键字

volatile 可以说是 JVM 提供的最轻量级的同步机制,当一个变量定义为volatile之后,它将具备两种特性:

**保证此变量对所有线程的可见性。**而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成。
注意,volatile 虽然保证了可见性,但是 Java 里面的运算并非原子操作,导致 volatile 变量的运算在并发下一样是不安全的。而 synchronized 关键字则是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得线程安全的。

**禁止指令重排序优化。**普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

(二)、Java垃圾收集和内存分配策略

如何确定某个对象是“垃圾”?

这一小节先了解一个最基本的问题:如果确定某个对象是“垃圾”?既然垃圾收集器的任务是回收垃圾对象所占的空间供新的对象使用,那么垃圾收集器如何确定某个对象是“垃圾”?通过什么方法判断一个对象可以被回收了。

在java中是通过引用来和对象进行关联的,也就是说如果要操作对象,必须通过引用来进行。那么很显然一个简单的办法就是通过引用计数来判断一个对象是否可以被回收。不失一般性,如果一个对象没有任何引用与之关联,则说明该对象基本不太可能在其他地方被使用到,那么这个对象就成为可被回收的对象了。这种方式成为引用计数法

这种方式的特点是实现简单,而且效率较高,但是它无法解决循环引用的问题,因此在Java中并没有采用这种方式(Python采用的是引用计数法)。

为了解决这个问题,在Java中采取了可达性分析法。该方法的基本思想是通过一系列的“GC Roots”对象作为起点进行搜索,如果在“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的,不过要注意的是被判定为不可达的对象不一定就会成为可回收对象。被判定为不可达的对象要成为可回收对象必须至少经历两次标记过程,如果在这两次标记过程中仍然没有逃脱成为可回收对象的可能性,则基本上就真的成为可回收对象了。

典型的垃圾收集算法

在确定了哪些垃圾可以被回收后,垃圾收集器要做的事情就是开始进行垃圾回收,但是这里面涉及到一个问题是:如何高效地进行垃圾回收。由于Java虚拟机规范并没有对如何实现垃圾收集器做出明确的规定,因此各个厂商的虚拟机可以采用不同的方式来实现垃圾收集器,所以在此只讨论几种常见的垃圾收集算法的核心思想。

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

这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。具体过程如下图所示:
在这里插入图片描述

从图中可以很容易看出标记-清除算法实现起来比较容易,但是有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。

2.Copying(复制)算法

为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。具体过程如下图所示:
在这里插入图片描述
这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。

3.Mark-Compact(标记-整理)算法(压缩法)

为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。具体过程如下图所示:
在这里插入图片描述

4.Generational Collection(分代收集)算法

分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

目前大部分垃圾收集器对于新生代都采取复制算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。

而由于老年代的特点是每次回收都只回收少量对象,一般使用的是标记-整理算法(压缩法)。

典型的垃圾收集器

垃圾收集算法是 内存回收的理论基础,而垃圾收集器就是内存回收的具体实现。下面介绍一下HotSpot(JDK 7)虚拟机提供的几种垃圾收集器,用户可以根据自己的需求组合出各个年代使用的收集器。

1.Serial/Serial Old收集器 是最基本最古老的收集器,它是一个单线程收集器,并且在它进行垃圾收集时,必须暂停所有用户线程。Serial收集器是针对新生代的收集器,采用的是Copying算法,Serial Old收集器是针对老年代的收集器,采用的是Mark-Compact算法。它的优点是实现简单高效,但是缺点是会给用户带来停顿。

2.ParNew收集器 是Serial收集器的多线程版本,使用多个线程进行垃圾收集。

3.Parallel Scavenge收集器 是一个新生代的多线程收集器(并行收集器),它在回收期间不需要暂停其他用户线程,其采用的是Copying算法,该收集器与前两个收集器有所不同,它主要是为了达到一个可控的吞吐量。

4.Parallel Old收集器 是Parallel Scavenge收集器的老年代版本(并行收集器),使用多线程和Mark-Compact算法。

5.CMS(Current Mark Sweep)收集器 是一种以获取最短回收停顿时间为目标的收集器,它是一种并发收集器,采用的是Mark-Sweep算法。

6.G1收集器 是当今收集器技术发展最前沿的成果,它是一款面向服务端应用的收集器,它能充分利用多CPU、多核环境。因此它是一款并行与并发收集器,并且它能建立可预测的停顿时间模型。

内存分配与回收策略

堆空间的基本结构:

在这里插入图片描述

  • Java 堆还可以细分为:新生代和老年代
  • 再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内
  • 上图所示的 eden 区、s0(“From”) 区、s1(“To”) 区都属于新生代,tentired 区属于老年代。

(1)优先分配到Eden

  • 大部分情况,对象都会首先在 Eden 区域分配,
  • 在Eden中没有足够的空间分配的时候,会发生一次MiniorGC

以下对两种GC先做解释:
在这里插入图片描述

JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代,因此GC按照回收的区域又分为两种类型:

  • 普通GC(Minor GC):只针对新生代区域的GC,因为Java对象大多都具备朝生夕灭的特性,所以MiniorGC非常频繁,一般回收速度也比较快

  • 全局GC(Major GC or Full GC):针对老年代的GC,偶尔伴随对新生代的minor GC以及方法区的GC,Major GC的速度一般会比Minor GC慢10倍以上

MinorGC的过程(复制->清空->互换):

  • 1.eden、SurvivorFrom复制到SurvivorTo,年龄+1

首先,当Eden区满的时候会触发第一次Minor GC,把还活着的对象拷贝到SurrvivorFrom区,
当Eden区再次触发GC的时候会扫描Eden区和From区域,对这两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到To区域(如果对象的年龄已经达到了老年的标准,则复制到老年代区),同时把Eden过来的对象和From区域过来的对象的年龄分别+1(如果这是第二次Minor GC的话,那Eden过来的对象的年龄变成了1,From区域过来的对象的年龄变成2,From区域的对象是上一次MinorGC从Eden中过来的,当时对象年龄+1)

  • 2.清空eden、SurvivorFrom

然后清空Eden和SurvivorFrom中的对象,这个时候,"From"和"To"会交换他们的角色,也就是新的"To"就是上次GC前的“From”,,直到“To”区被填满,"To"区被填满之后,会将所有对象移动到年老代中,

  • 3.SurvivorTo和SurvivorFrom互换

·最后,SurvivorTo和SurvivorFrom互换,原SurvivorTo成为下一次GC时的SurvivorFrom区。新的"From"就是上次GC前的"To"。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,部分对象会在From和To区域中复制来复制去,直到“To”区域被填满,或分代年龄达到设定值(由JVM参数MaxTenuring Threshold决定这个参数默认是15,而分代年龄的保存是在对象头当中),最终如果还是存活,就存入到老年代,总结起来就是:复制之后有交换,谁空谁是to
注意:

  • 1.当JVM无法为一个新的对象分配空间时会触发Minor GC,例如当Eden区满了,所以分配的频率越高,执行Minor GC的频率也可能越频繁。

  • 2.所有的Minor GC都会触发“stop-the-world”,停止应用程序的线程。对于大部分应用程序,停顿导致的延迟都是可以忽略不计的。

Full GC:

  • 对整个堆进行整理,包括Young Generation、Old Generation,Full GC因为需要对整个堆进行回收,所以比Minor GC要慢,因此应该尽可能减少Full GC的次数。

  • 有如下原因可能导致 Full GC:·

  • 年老代(Tenured)被写满、

    • System.gc()被显示调用 、
    • 上一次 GC 之后 Heap 的各域分配策略动态变化。

(2)大对象直接分配到老年代

所谓大对象指的是需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组

为什么要将它们直接放到老年代呢?

  • 我们知道分配在Eden上的对象在垃圾回收过程中会在Eden以及Survivor区之间进行来回复制,而如果是一个大对象,这样的多次来回复制显然降低了性能,所以干脆直接分配到老年代中

那么多大的对象才算做大对象?

JVM提供了-XX:PretenureSizeThreshold参数来让我们设置大对象的阈值,即大于等于这个设定值就会直接放到老年代中

  • 注意:这个参数只对Serial和ParNew两款收集器有效,Parallel Scanvenge不认识这个参数所以一般不需要配置,如果遇到必须使用此参数的场合,可以考虑ParNew加CMS收集器的组合

代码演示:

1package JVM; 2 3public class Main { 4 public static void main(String[] args) { 5 byte[] b1 = new byte[3*1024*1024]; 6 } 7} 8 9

在这里插入图片描述

在这里插入图片描述

  • 可以发现我将该参数设置为3M时,定义一个3M的byte数组对象,它直接分配到了老年代当中

package JVM;

1public class Main { 2 public static void main(String[] args) { 3 byte[] b1 = new byte[2*1024*1024]; 4 } 5} 6 7

在这里插入图片描述

  • 当我字节数组修改为2M时,它就不是一个“大对象”,直接会在Eden中创建

(3)长期存活的对象分配到老年代

  • 既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。

  • 为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。而这个年龄存放在对象的对象头当中

  • 通过前面我们知道在Eden到SurvivorTo或者SurvivorFrom到SurvivorTo中都会让对象的年龄+1

那么多大的年龄算是长期呢?

  • 同样,JVM提供了参数-XX:MaxTenuringThreshold来设置这个年龄阈值,即在年轻代中复制来复制去,年龄增加到年龄阈值的时候就会被移动到老年代中

  • 此参数默认为15,并且我们能设置的范围只能是0~15

(4)空间分配担保

在这里插入图片描述

在发生Minor GC之前,虚拟机会先1.检查老年代中最大可用的连续空间是否大于新生代所有对象总空间

  • 成立,MinorGC是安全的

  • 不成立,2.继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小

  • 大于,将尝试着进行一次MinorGC,尽管这次MinorGC是有风险的,

    • 小于,FullGC

(5)动态对象的年龄判定

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

(6)逃逸分析和栈上分配

逃逸分析是Java虚拟机中比较前沿的优化技术,它不是代码的直接优化,而是为其他优化提供的分析技术

逃逸分析的基本行为就是分析对象动态作用域

  • 方法逃逸:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸

  • 线程逃逸:当一个对象在方法中被定义后,它可能被外部线程所引用,例如给类变量赋值或可以在其他线程中访问的实例变量,称为线程逃逸

栈上分配:

  • 我们知道一般对象都是在堆中分配,但无论是筛选可回收对象,还是回收和整理内存都需要耗费时间,

  • 但是有了逃逸技术之后,如果能确定一个对象不会逃逸到方法之外,就可以让这个对象直接在栈上分配内存

这样对象所占用的内存空间就可以随着帧出栈而销毁

  • 在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的队形就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多

示例:

1package JVM; 2 3public class StackAllocation { 4 5 private StackAllocation obj; 6 7 //方法返回StackAllocation对象,发生逃逸 8 public StackAllocation getInstance(){ 9 return obj==null? new StackAllocation():obj; 10 } 11 12 //为成员变量赋值,发生逃逸 13 public void setObj(){ 14 obj = new StackAllocation(); 15 } 16 17 //对象的作用域仅在当前方法中持有,没有发生逃逸 18 public void useStackAllocation(){ 19 StackAllocation s = new StackAllocation(); 20 21 } 22 //引用成员变量的值,发生逃逸 23 public void useStackAllocation2(){ 24 StackAllocation s = getInstance(); 25 } 26 27} 28 29

(三)、Java类文件结构

1、任何一个类的class文件都对应这唯一一个类或者接口的定义信息。但反过来说类和接口不一定得定义在文件里,也可以由类加载器直接生成。

2、Class文件是以8位字节(byte)为基础单位的二进制文件。根据虚拟机规范的规定,Class文件格式采用一种类似与C语言结构体的伪结构来存储数据,这种伪结构只存储两种数据类型:无符号数和表。

无符号数:基本数据类型,以u1,u2,u4,u8来分别代表1个字节,2个字节,4个字节和8个字节的无符号数。而其中,无符号数可以用来描述数字,索引引用,数值量或者按照UTF-8编码构造成字符串。

表:由多个无符号数构成或者由多个表构成,所有的表都以"_info"结尾。整个class文件本质上就是一张表。
在这里插入图片描述

上表已经看到了class文件的结构,之后会介绍各种数据类型

魔数(magic)与class文件的版本

magic是每个Class文件的头四个字节,起作用只有一个:确定这个文件是否为一个能被虚拟机接受的Class文件。

许多的文件都用魔数进行身份识别,譬如图片(gif,jpeg)都存在魔数。其值为0xCAFEBABE(四个字节)

紧接着魔数的两个字节是minor_version(此版本号),再接着两个字节是major_version(主版本号)。JAVA虚拟机必须拒绝之宗超过其版本号的Class文件

JDK1.1对应版本号:45.0-45.65535 JDK1.2则能支持46.0-46.65535的Class文件

JDK1.7可生成的主版本号最大为51.0

常量池(constant_pool_count(u2)与constantpool (cp_info) )

constant_pool_count从1开始计数,不是从0开始。设计者将0空出来,是为了满足后面某些指向常量池的索引值的数据在特定的情况下需要表达“不引用任何一个常量池项目”。

常量池中存放两大类常量:字面量(literal)与符号引用(symbolic reference)

字面量:文本字符串,声明为final的常量值

符号引用:包含三大类常量:

类和接口的全限定名 (Fully Qualified Name)、字段的名称和描述符、方法的名称和描述符

当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建或运行时解析、翻译到具体的内存地址中。

常量池的每一项常量都是一个表(constant_pool是由表组成的表)
在这里插入图片描述

常量池中的表的种类如上图,这些表在开始的第一位是一个u1类型的标志位,用来表示这个常量所属的类型

每一种常量类型都有它特定的数据结构:比如CONSTANT_Class_info
在这里插入图片描述

再如CONSTANT_uTF8_INFO类型:

在这里插入图片描述

在这里插入图片描述

通过javap -verbose可以帮助我们查看类文件结构。

在这里插入图片描述

某些没有出现过的常量在字段表、方法表、属性表中会被引用。

访问标志

常量池结束后,接着的两个字节代表访问标志,用于识别一些类或者接口层次的访问信息
在这里插入图片描述

access_flag中一共有16个标志位,但目前只定义了8个,没有使用到的标志位要求为0.

类索引、父类索引、接口索引集合

类索引(this class) u2

父类索引(super class) u2(只允许继承一个类)

interfaces_count u2(允许实现多个接口)

interfaces interfaces_count
在这里插入图片描述

可以了解到,java中除了object类,其他所有类都是有父类的。

字段表集合

字段表(field_info)用于描述接口或者类中声明的变量,包括类级变量以及实例级变量,不包括方法中的局部变量。
在这里插入图片描述

其中的access_flags:

在这里插入图片描述

以下两项都是对常量池的引用

name_index:字段的简单名称以及字段 (简单名称:没有类型和参数修饰的方法或者字段名称)

descriptor_index:方法的描述符

在这里插入图片描述

对于数组用前置的"["来描述每个维度

如java.lang.String[][]表示成[[Ljava/lang/String

全限定名:如org/fenz/calss/test/class

简单名称:没有类型和参数修饰的方法或者字段名称

描述符:用来描述字段或者方法的数据类型,方法的参数列表和返回值。

在这里插入图片描述

方法表集合

在这里插入图片描述
在这里插入图片描述

属性表集合

(四)、Java类加载机制

在这里插入图片描述
类加载器负责将class文件读入内存,并为之生成对应的java.lang.Class对象。

加载

  • 通过一个类的全限定名来获取定义此类的二进制字节流。

  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

  • 通常可以用如下几种方式加载类的二进制数据:

  • 从本地文件系统加载class文件。

    • 从JAR包中加载class文件,如JAR包的数据库启驱动类。

    • 通过网络加载class文件。

    • 把一个Java源文件动态编译并执行加载。

    • 其他文件生成,典型的场景JSP应用,即由JSP文件生成对应的Class类

连接

连接阶段负责把类的二进制数据合并到JRE中,其又可分为如下三个阶段:

验证:确保加载的类信息符合JVM规范,无安全方面的问题。

准备:为类的静态Field分配内存,并设置初始值。

解析:将类的二进制数据中的符号引用替换成直接引用。

初始化

  • 该阶段主要是对静态Field进行初始化,在Java类中对静态Field指定初始值有两种方式:

声明时即指定初始值,如static int a = 5;

使用静态代码块为静态Field指定初始值,如:static{ b = 5; }

JVM初始化一个类包含如下几个步骤:

  1. 假如这个类还没有被加载和连接,则程序先加载并连接该类。
  2. 假如该类的直接父类还没有被初始化,则先初始化其直接父类。
  3. 假如类中有初始化语句,则系统依次执行这些初始化语句。

所以JVM总是最先初始化java.lang.Object类。

类初始化的时机(对类进行主动引用时):

创建类的实例时(new、反射、反序列化)。

调用某个类的静态方法时。

使用某个类或接口的静态Field或对该Field赋值时。

使用反射来强制创建某个类或接口对应的java.lang.Class对象,如Class.forName(“Person”)

初始化某个类的子类时,此时该子类的所有父类都会被初始化。

直接使用java.exe运行某个主类时。

1.Java9之前

类加载器

当JVM启动时,会形成有3个类加载器组成的初始类加载器层次结构:

(1).Bootstrap ClassLoader:根类(或叫启动、引导类加载器)加载器。

它负责加载Java的核心类(如String、System等)。它比较特殊,因为它是由原生C++代码实现的,并不是java.lang.ClassLoader的子类,所以下面的运行结果为null:

1public class TestJdkCl { 2 public static void main(String[] args) { 3 System.out.println(String.class.getClassLoader()); 4 } 5} 6 7

(2).Extension ClassLoader:扩展类加载器。

它负责加载JRE的扩展目录(%JAVA_HOME%/jre/lib/ext)中JAR包的类,我们可以通过把自己开发的类打包成JAR文件放入扩展目录来为Java扩展核心类以外的新功能。

(3).System ClassLoader(或Application ClassLoader):系统类加载器。

它负责在JVM启动时加载来自Java命令的-classpath选项、java.class.path系统属性,或CLASSPATH环境变量所指定的JAR包和类路径。程序可以通过ClassLoader的静态方法getSystemClassLoader来获取系统类加载器:

1public class TestJdkCl { 2 //获取主类的类加载器 3 public static void main(String[] args) { 4 System.out.println(TestJdkCl.class.getClassLoader().getClass().getName()); 5 System.out.println(ClassLoader.getSystemClassLoader().getClass().getName()); 6 } 7} 8 9

运行结果:

在这里插入图片描述

类加载机制

JVM的类加载机制主要有以下3种:

  • 全盘负责:当一个类加载器加载某个Class时,该Class所依赖和引用的其它Class也将由该类加载器负责载入,除非显式的使用另外一个类加载器来载入。
  • 双亲委派:当一个类加载器收到了类加载请求,它会把这个请求委派给父(parent)类加载器去完成,依次递归,因此所有的加载请求最终都被传送到顶层的启动类加载器中。只有在父类加载器无法加载该类时子类才尝试从自己类的路径中加载该类。(注意:类加载器中的父子关系并不是类继承上的父子关系,而是类加载器实例之间的关系。)
  • 缓存机制:缓存机制会保证所有加载过的Class都会被缓存,当程序中需要使用某个类时,类加载器先从缓冲区中搜寻该类,若搜寻不到将读取该类的二进制数据,并转换成Class对象存入缓冲区中。这就是为什么修改了Class后需重启JVM才能生效的原因。

2.Java9的改变

类加载器

JDK 9保持三级分层类加载器架构以实现向后兼容。但是,从模块系统加载类的方式有一些变化。且新增Platform ClassLoader:平台类加载器,用于加载一些平台相关的模块,例如: java.activation 、 java.se 、 jdk.desktop 、 java.compiler 等,双亲是BootClassLoader。 JDK 9类加载器层次结构如下图所示。
在这里插入图片描述

可见,在JDK 9中,应用程序类加载器可以委托给平台类加载器以及引导类加载器;平台类加载器可以委托给引导类加载器和应用程序类加载器。

此外,JDK 9不再支持扩展机制。 但是,它将扩展类加载器保留在名为平台类加载器的新名称下。 ClassLoader类包含一个名为getPlatformClassLoader()的静态方法,该方法返回对平台类加载器的引用。

在JDK 9之前,扩展类加载器和应用程序类加载器都是java.net.URLClassLoader类的一个实例。 而在JDK 9中,平台类加载器(以前的扩展类加载器)和应用程序类加载器是内部JDK类的实例。 如果你的代码依赖于URLClassLoader类的特定方法,代码可能会在JDK 9中崩溃。

1public class TestJdkCl { 2 public static void main(String[] args) { 3 ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();//应用程序类加载器 4 System.out.println(appClassLoader.getClass().getName()); 5 ClassLoader plaClassLoader = appClassLoader.getParent();//应用程序类加载器的父类 6 System.out.println(plaClassLoader.getClass().getName()); 7 System.out.println(plaClassLoader.getParent());//平台类加载器的父类 8 } 9} 10 11

运行结果:
在这里插入图片描述

类加载机制

JDK 9中的类加载机制有所改变。 三个内置的类加载器一起协作来加载类。

  • 当应用程序类加载器需要加载类时,它将搜索定义到所有类加载器的模块。 如果有合适的模块定义在这些类加载器中,则该类加载器将加载类,这意味着应用程序类加载器现在可以委托给引导类加载器和平台类加载器。 如果在为这些类加载器定义的命名模块中找不到类,则应用程序类加载器将委托给其父类,即平台类加载器。 如果类尚未加载,则应用程序类加载器将搜索类路径。 如果它在类路径中找到类,它将作为其未命名模块的成员加载该类。 如果在类路径中找不到类,则抛出ClassNotFoundException异常。
  • 当平台类加载器需要加载类时,它将搜索定义到所有类加载器的模块。 如果一个合适的模块被定义为这些类加载器中,则该类加载器加载该类。 这意味着平台类加载器可以委托给引导类加载器以及应用程序类加载器。 如果在为这些类加载器定义的命名模块中找不到一个类,那么平台类加载器将委托给它的父类,即引导类加载器。
  • 当引导类加载器需要加载一个类时,它会搜索自己的命名模块列表。 如果找不到类,它将通过命令行选项-Xbootclasspath/a指定的文件和目录列表进行搜索。 如果它在引导类路径上找到一个类,它将作为其未命名模块的成员加载该类。

代码交流 2021