Java基础-JVM


前言:
JVM一直是java知识里面进阶阶段的重要部分,如果希望在java领域研究的更深入,则JVM则是如论如何也避开不了的话题,本系列试图通过简洁易读的方式,讲解JVM必要的知识点。

一. JVM是什么

JVM它是Java Virtual Machine 的缩写,主要是通过在实际计算机模仿各种计算机功能来实现的,组成部分包括堆、方法区、栈、本地方法栈、程序计算器等部分组成的,其中方法回收堆和方法区是共享区,也就是谁都可以使用,而栈和程序计算器、本地方法栈区是归JVM的。Java能够被称为“一次编译,到处运行”的原因就是Java屏蔽了很多的操作系统平台相关信息,使得Java只需要生成在JVM虚拟机运行的目标代码也就是所说的字节码,就可以在多种平台运行。

二.JVM、JDK、JRE三者的关系

1.JVM

Java 虚拟机(JVM)是运⾏ Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),⽬的是使⽤相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语⾔“⼀次编译,随处可以运⾏”的关键所在.

什么是字节码?采⽤字节码的好处是什么?

在 Java 中,JVM 可以理解的代码就叫做 字节码 (即扩展名为 .class 的⽂件),它不⾯向任
何特定的处理器,只⾯向虚拟机。Java 语⾔通过字节码的⽅式,在⼀定程度上解决了传统解释型语
⾔执⾏效率低的问题,同时⼜保留了解释型语⾔可移植的特点。所以 Java 程序运⾏时⽐᫾⾼效,
⽽且,由于字节码并不针对⼀种特定的机器,因此,Java 程序⽆须重新编译便可在多种不同操作系
统的计算机上运⾏。

Java 程序从源代码到运⾏⼀般有下⾯ 3 步:
编译步骤

2.JDK

JDK 是 Java Development Kit,它是功能⻬全的 Java SDK。它拥有 JRE 所拥有的⼀切,还有编译器(javac)和⼯具(如 javadoc 和 jdb)。它能够创建和编译程序。

3.JRE

JRE 是 Java 运⾏时环境。它是运⾏已编译 Java 程序所需的所有内容的集合,包括 Java 虚拟机(JVM),Java 类库,java 命令和其他的⼀些基础构件。但是,它不能⽤于创建新程序。

三.JVM内存区域划分

粗略分来,JVM的内部体系结构分为三部分,分别是:类装载器(ClassLoader)子系统,运行时数据区,和执行引擎。

1.类装载器

每一个Java虚拟机都由一个类加载子系统(class loader subsystem)负责加载程序中的类型(类和接口),并赋予唯一的名字。每一个 Java 虚拟机都有一个执行引擎(execution engine)负责执行被加载类中包含的指令。 JVM 的两种类装载器包括:启动类装载器和用户自定义类装载器,启动类装载器是 JVM 实现的一部分,用户自定义装载器则是Java程序的一部分,必须是 ClassLoader 类的子类

2.执行引擎

主要的执行技术有:解释,即时编译,自适应优化、芯片级直接执行其中解释属于第一代JVM,即时编译JIT属于第二代JVM,自适应优化(目前Sun的HotspotJVM采用这种技术)则吸取第一代JVM和第二代JVM的经验,采用两者结合的方式 。

自适应优化:开始对所有的代码都采取解释执行的方式,并监视代码执行情况,然后对那些经常调用的方法启动一个后台线程,将其编译为本地代码,并进行仔细优化。若方法不再频繁使用,则取消编译过的代码,仍对其进行解释执行。

3.运行时数据区

主要包括:方法区,堆,Java栈,PC寄存器,本地方法栈

  1. 方法区和堆由所有线程共享

堆:存放所有程序在运行时创建的对象

方法区:当JVM的类装载器加载.class文件,并进行解析,把解析的类型信息放入方法区。

  1. Java栈和PC寄存器由线程独享

    JVM栈是线程私有的,每个线程创建的同时都会创建JVM栈,JVM栈中存放的为当前线程中局部基本类型的变量(java中定义的八种基本类型:boolean、char、byte、short、int、long、float、double)、部分的返回结果以及Stack Frame,非基本类型的对象在JVM栈上仅存放一个指向堆上的地址

  2. 本地方法栈:存储本地方法调用的状态

四.JVM运行时数据区

Java 虚拟机在执⾏ Java 程序的过程中会把它管理的内存划分成若⼲个不同的数据区域。JDK. 1.8 和
之前的版本略有不同,下⾯会介绍到。

  1. JDK 1.8 之前
    JDK1.8之前
  2. JDK 1.8
    JDK1.8
    详版

线程隔离的:
程序计数器
虚拟机栈
本地方法栈

线程共享:

方法区(元数据区)
直接内存(非运行时数据区的一部分)

JVM1.8同1.7比,最大的差别就是:元数据区取代了永久代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元数据空间并不在虚拟机中,而是使用本地内存。

1.程序计数器

程序计数器是一块比较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器,是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器。

Java 虚拟机的多线程是通过线程轮流切换,分配处理器时间的方式来实现的,所以在任何一个确定的时刻,一个处理器(即多处理器的一个内核)都只会执行一条线程中的指令。因此,为了线程切换后,能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各个线程之间不影响,独立存储,我们称这类内存区域为“线程私有”。

如果线程执行的是Java方法,则记录的是正在执行的虚拟机字节码指令的地址。如果是Native 本地方法,计数器值为空(Undefined)

从上面的介绍中我们知道程序计数器主要有两个作用:

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

注意:程序计数器是唯⼀⼀个不会出现 OutOfMemoryError 的内存区域,它的⽣命周期随着线程的创建
⽽创建,随着线程的结束⽽死亡。是唯一一个在《java虚拟机规范》中没有规定任何 OOM 情况的区域。

2.Java 虚拟机栈

线程私有,每个线程对应一个Java虚拟机栈,其生命周期与线程同进同退。每个Java方法(也就是字节码)在被调用的时候都会创建一个栈帧,并入栈。一旦完成调用,则出栈。所有的的栈帧都出栈后,线程也就完成了使命。
Java虚拟机栈
Java虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于保存 局部变量表、操作数栈、动态链接、方法出口等信息。每个方法被调用直至执行完毕的过程,对应了栈帧在虚拟机栈中入栈到出栈的过程。

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和 returnAddress 类型(指向一条字节码指令的地址)。

Java 虚拟机栈会出现两种错误:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError: 若Java虚拟机栈的内存⼤⼩不允许动态扩展,那么当线程请求栈的深度
    超过当前Java虚拟机栈的最⼤深度的时候,就抛出StackOverFlowError异常。
  • OutOfMemoryError: 若 Java 虚拟机栈的内存⼤⼩允许动态扩展,且当线程请求栈时内存⽤完
    了,⽆法再动态扩展了,此时抛出OutOfMemoryError异常。

Java 方法有两种返回方式:(1)return 语句;(2)抛出异常,不管哪种返回方式都会导致栈帧被弹出。

3.本地方法栈

功能与Java虚拟机栈十分相同。区别在于,虚拟机栈为虚拟机执⾏ Java ⽅法 (也就是字节码)服
务,而本地方法栈为虚拟机使用到的native方法服务。

4.堆

Java 虚拟机所管理的内存中最⼤的⼀块,Java 堆是所有线程共享的⼀块内存区域,在虚拟机启动时创
建。此内存区域的唯⼀⽬的就是存放对象实例,⼏乎所有的对象实例以及数组都在这⾥分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap).从垃圾回收的
⻆度,由于现在收集器基本都采⽤分代垃圾收集算法,所以Java堆还可以细分为:新⽣代和⽼年代:再
细致⼀点有:Eden空间、From Survivor、To Survivor空间等。进⼀步划分的⽬的是更好地回收内存,
或者更快地分配内存。
heap
堆空间内存分配(默认情况下)

  • 老年代
  • 年轻代
    • eden区:8/10 的年轻代空间
    • survivor0:1/10 的年轻代空间
    • survivor1:1/10 的年轻代空间

命令行上执行如下命令,查看所有默认的jvm参数

java -XX:+PrintFlagsFinal -version

输出有大几百行,这里只取其中的两个有关联的参数

[Global flags]
	uintx InitialSurvivorRatio                      = 8                                   {product}
	uintx NewRatio                                  = 2                                   {product}
	... ...
java version "1.8.0_152"
Java(TM) SE Runtime Environment (build 1.8.0_152-b16)
Java HotSpot(TM) 64-Bit Server VM (build 25.152-b16, mixed mode)
参数作用
-XX:InitialSurvivorRatio新生代Eden/survivor空间的初始比例
-XX:NewRatioOld区和Yong区的内存比例

一道推算题

默认参数下,如果仅给出eden区40M,求堆空间总大小

答:根据比例可以推算出,两个survivor区各5M,年轻代50M。老年代是年轻代的两倍,即100M。那么堆总大小就是150M。

5.方法区

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

⽅法区也被称为永久代。很多⼈都会分不清⽅法区和永久代的关系,为此我也查阅了⽂献。

⽅法区和永久代的关系

《Java虚拟机规范》只是规定了有⽅法区这么个概念和它的作⽤,并没有规定如何去实现它。那
么,在不同的 JVM 上⽅法区的实现肯定是不同的了。 ⽅法区和永久代的关系很像Java中接⼝和类
的关系,类实现了接⼝,⽽永久代就是HotSpot虚拟机对虚拟机规范中⽅法区的⼀种实现⽅式。 也
就是说,永久代是HotSpot的概念,⽅法区是Java虚拟机规范中的定义,是⼀种规范,⽽永久代是⼀
种实现,⼀个是标准⼀个是实现,其他的虚拟机实现并没有永久带这⼀说法。

(1)JDK 1.8 之前永久代还没被彻底移除的时候通常通过下⾯这些参数来调节⽅法区⼤⼩

-XX:PermSize=N 		// ⽅法区(永久代)初始⼤⼩
-XX:MaxPermSize=N 	// ⽅法区(永久代)最⼤⼤⼩,超过这个值将会抛出

(2)JDK 1.8 的时候,⽅法区(HotSpot的永久代)被彻底移除了(JDK1.7就已经开始了),取⽽代之是元空间,元空间使⽤的是直接内存。

-XX:MetaspaceSize=N 	//设置Metaspace的初始(和最⼩⼤⼩)
-XX:MaxMetaspaceSize=N 	//设置Metaspace的最⼤⼤⼩

与永久代很⼤的不同就是,如果不指定⼤⼩的话,随着更多类的创建,虚拟机会耗尽所有可⽤的系统内存。

6.运行时常量池

运⾏时常量池是⽅法区的⼀部分。Class ⽂件中除了有类的版本、字段、⽅法、接⼝等描述信息外,还
有常量池信息(⽤于存放编译期⽣成的各种字⾯量和符号引⽤)
既然运⾏时常量池时⽅法区的⼀部分,⾃然受到⽅法区内存的限制,当常量池⽆法再申请到内存时会抛
出 OutOfMemoryError 异常。
运行时常量池

  • JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代
  • JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代 。
  • JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)

7.直接内存

直接内存并不是虚拟机运⾏时数据区的⼀部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使⽤。⽽且也可能导致 OutOfMemoryError 异常出现。

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

本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

五. Java 对象的创建过程

Java对象创建过程

  1. 类加载检查: 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
  2. 分配内存: 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有“指针碰撞”和“空闲列表”两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
  3. 初始化零值: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  4. 设置对象头: 初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式。
  5. 执行 init 方法: 在上⾯⼯作都完成之后,从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但从Java 程序的视⻆来看,对象创建才刚开始, init ⽅法还没有执⾏,所有的字段都还为零。所以⼀般来说,执⾏ new 指令之后会接着执⾏ init ⽅法,把对象按照程序员的意愿进⾏初始化,这样⼀个真正可⽤的对象才算完全产⽣出来。

(1)内存分配的两种方式

选择以上两种⽅式中的哪⼀种,取决于 Java 堆内存是否规整。⽽ Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的

(2)内存分配并发问题

在创建对象的时候有⼀个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采⽤两种⽅式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的⼀种实现⽅式。所谓乐观锁就是,每次不加锁⽽是假设没有冲突⽽去完成某项操作,如果因为冲突失败就重试,直到成功为⽌。虚拟机采⽤ CAS 配上失败重试的⽅式保证更新操作的原⼦性。

  • TLAB: 为每⼀个线程预先在Eden区分配⼀块⼉内存,JVM在给线程中的对象分配内存时,⾸先在TLAB分配,当对象⼤于TLAB中的剩余内存或TLAB的内存已⽤尽时,再采⽤上述的CAS进⾏内存分配

六. 对象的访问定位方式

建⽴对象就是为了使⽤对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问⽅式有虚拟机实现⽽定,⽬前主流的访问⽅式有 ①使⽤句柄 和 ②直接指针 两种:

1.句柄

如果使⽤句柄的话,那么Java堆中将会划分出⼀块内存来作为句柄池,reference 中存储的就是对象的句柄地址,⽽句柄中包含了对象实例数据与类型数据各⾃的具体地址信息;
句柄

2.直接指针

如果使⽤直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,⽽reference 中存储的直接就是对象的地址。
直接指针
这两种对象访问⽅式各有优势。使⽤句柄来访问的最⼤好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,⽽ reference 本身不需要修改。使⽤直接指针访问⽅式最⼤的好处就是速度快,它节省了⼀次指针定位的时间开销。

七. 堆内存中对象分配的基本策略

堆空间的基本结构:
堆内存
上图所示的 eden区、s0区、s1区都属于新⽣代,tentired 区属于⽼年代。⼤部分情况,对象都会⾸先在 Eden 区域分配,在⼀次新⽣代垃圾回收后,如果对象还存活,则会进⼊ s0 或者 s1,并且对象的年龄还会加 1(Eden区i>Survivor 区后对象的初始年龄变为1),当它的年龄增加到⼀定程度(默认为15岁),就会被晋升到⽼年代中。对象晋升到⽼年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

另外,⼤对象和⻓期存活的对象会直接进⼊⽼年代。
分配策略

八. Minor Gc和Full GC 的区别

⼤多数情况下,对象在新⽣代中 eden 区分配。当 eden 区没有⾜够空间进⾏分配时,虚拟机将发起⼀
次 Minor GC。

  • 新⽣代GC(Minor GC): 指发⽣新⽣代的的垃圾收集动作,Minor GC⾮常频繁,回收速度⼀般也比较快。
  • ⽼年代GC(Major GC/Full GC): 指发⽣在⽼年代的GC,出现了Major GC经常会伴随⾄少⼀次的Minor GC(并⾮绝对),Major GC的速度⼀般会⽐Minor GC的慢10倍以上。

九.如何判断对象是否死亡

堆中⼏乎放着所有的对象实例,对堆垃圾回收前的第⼀步就是要判断哪些对象已经死亡(即不能再被任何途径使⽤的对象)。

1. 引用计数法

给对象中添加⼀个引⽤计数器,每当有⼀个地⽅引何时候计数器为0的对象就是不可能再被使⽤的。

2. 可达性分析算法

这个算法的基本思想就是通过⼀系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所⾛过的路径称为引⽤链,当⼀个对象到 GC Roots 没有任何引⽤链相连的话,则证明此对象是不可⽤的。
可达性分析

十.什么是强引用,软引用,弱引用,虚引用

⽆论是通过引⽤计数法判断对象引⽤数量,还是通过可达性分析法判断对象的引⽤链是否可达,判定对象的存活都与“引⽤”有关。

JDK1.2之前,Java中引⽤的定义很传统:如果reference类型的数据存储的数值代表的是另⼀块内存的起始地址,就称这块内存代表⼀个引⽤。

JDK1.2以后,Java对引⽤的概念进⾏了扩充,将引⽤分为强引⽤、软引⽤、弱引⽤、虚引⽤四种(引⽤强度逐渐减弱)

  1. 强引⽤(StrongReference)
    以前我们使⽤的⼤部分引⽤实际上都是强引⽤,这是使⽤最普遍的引⽤。如果⼀个对象具有强引⽤,那就类似于必不可少的⽣活⽤品,垃圾回收器绝不会回收它。当内存空 间不⾜,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终⽌,也不会靠随意回收具有强引⽤的对象来解决内存不⾜问题。

  2. 软引⽤(SoftReference)
    如果⼀个对象只具有软引⽤,那就类似于可有可⽆的⽣活⽤品。如果内存空间⾜够,垃圾回收器就不会回收它,如果内存空间不⾜了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使⽤。软引⽤可⽤来实现内存敏感的⾼速缓存。

    软引⽤可以和⼀个引⽤队列(ReferenceQueue)联合使⽤,如果软引⽤所引⽤的对象被垃圾回收,JAVA虚拟机就会把这个软引⽤加⼊到与之关联的引⽤队列中。

  3. 弱引⽤(WeakReference)
    如果⼀个对象只具有弱引⽤,那就类似于可有可⽆的⽣活⽤品。弱引⽤与软引⽤的区别在于:只具有弱引⽤的对象拥有更短暂的⽣命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,⼀旦发现了只具有弱引⽤的对象,不管当前内存空间⾜够与否,都会回收它的内存。不过,由于垃圾回收器是⼀个优先级很低的线程, 因此不⼀定会很快发现那些只具有弱引⽤的对象。

    弱引⽤可以和⼀个引⽤队列(ReferenceQueue)联合使⽤,如果弱引⽤所引⽤的对象被垃圾回收,Java虚拟机就会把这个弱引⽤加⼊到与之关联的引⽤队列中。

  4. 虚引⽤(PhantomReference)
    "虚引⽤"顾名思义,就是形同虚设,与其他⼏种引⽤都不同,虚引⽤并不会决定对象的⽣命周期。如果⼀个对象仅持有虚引⽤,那么它就和没有任何引⽤⼀样,在任何时候都可能被垃圾回收。

    虚引⽤主要⽤来跟踪对象被垃圾回收的活动。

    虚引⽤与软引⽤和弱引⽤的⼀个区别在于: 虚引⽤必须和引⽤队列(ReferenceQueue)联合使⽤。当垃 圾回收器准备回收⼀个对象时,如果发现它还有虚引⽤,就会在回收对象的内存之前,把这个虚引⽤加⼊到与之关联的引⽤队列中。程序可以通过判断引⽤队列中是 否已经加⼊了虚引⽤,来了解被引⽤的对象是否将要被垃圾回收。程序如果发现某个虚引⽤已经被加⼊到引⽤队列,那么就可以在所引⽤的对象的内存被回收之前采取必要的⾏动。

    特别注意,在程序设计中⼀般很少使⽤弱引⽤与虚引⽤,使⽤软引⽤的情况᫾多,这是因为软引⽤可以加速JVM对垃圾内存的回收速度,可以维护系统的运⾏安全,防⽌内存溢出(OutOfMemory)等问题的产⽣。

十一.如何判断⼀个常量是废弃常量

运⾏时常量池主要回收的是废弃的常量。那么,我们如何判断⼀个常量是废弃常量呢?

假如在常量池中存在字符串 “abc”,如果当前没有任何String对象引⽤该字符串常量的话,就说明常量
“abc” 就是废弃常量,如果这时发⽣内存回收的话⽽且有必要的话,“abc” 就会被系统清理出常量池。

十二.如何判断⼀个类是⽆⽤的类

⽅法区主要回收的是⽆⽤的类,那么如何判断⼀个类是⽆⽤的类的呢?

判定⼀个常量是否是“废弃常量”比较简单,⽽要判定⼀个类是否是“⽆⽤的类”的条件则相对苛刻许多。类需要同时满⾜下⾯3个条件才能算是 “⽆⽤的类” :

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地⽅被引⽤,⽆法在任何地⽅通过反射访问该类的⽅法。

虚拟机可以对满⾜上述3个条件的⽆⽤类进⾏回收,这⾥说的仅仅是“可以”,⽽并不是和对象⼀样不使⽤了就会必然被回收。

十三.垃圾收集有哪些算法,各自的特点

1. 标记-清除算法

算法分为 “标记” 和 “清除” 阶段:⾸先标记出所有需要回收的对象,在标记完成后统⼀回收所有被标记的对象。它是最基础的收集算法,后续的算法都是对其不⾜进⾏改进得到。这种垃圾收集算法会带来两个明显的问题:

  • 效率问题
  • 空间问题(标记清除后会产⽣⼤量不连续的碎⽚)
    标记清除

2. 复制算法

为了解决效率问题,“复制”收集算法出现了。它可以将内存分为⼤⼩相同的两块,每次使⽤其中的⼀块。当这⼀块的内存使⽤完后,就将还存活的对象复制到另⼀块去,然后再把使⽤的空间⼀次清理掉。这样就使每次的内存回收都是对内存区间的⼀半进⾏回收,这种垃圾收集算法会带来两个明显的问题:

  • 内存被压缩到了原本的一半。
  • 存活对象增多的话,Copying 算法的效率会大大降低。

复制算法

3. 标记-整理算法

根据⽼年代的特点特出的⼀种标记算法,标记过程仍然与“标记-清除”算法⼀样,但后续步骤不是直接对可回收对象回收,⽽是让所有存活的对象向⼀端移动,然后直接清理掉端边界以外的内存。
标记整理

4. 分代收集算法

当前虚拟机的垃圾收集都采⽤分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为⼏块。⼀般将java堆分为新⽣代和⽼年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

比如在新⽣代中,每次收集都会有⼤量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。⽽⽼年代的对象存活⼏率是⽐较⾼的,⽽且没有额外的空间对它进⾏分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进⾏垃圾收集。

十四.HotSpot为什么要分为新生代和老年代

主要是为了提升GC效率。上⾯提到的分代收集算法已经很好的解释了这个问题。

十五.常见的垃圾回收器有哪些

垃圾收集器
如果说收集算法是内存回收的⽅法论,那么垃圾收集器就是内存回收的具体实现。

虽然我们对各个收集器进行比较,但并⾮要挑选出⼀个最好的收集器。因为知道现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应⽤场景选择适合⾃⼰的垃圾收集器。试想⼀下:如果有⼀种四海之内、任何场景下都适⽤的完美收集器存在,那么我们的 HotSpot 虚拟机就不会实现那么多不同的垃圾收集器了。

1. Serial收集器

Serial(串行)收集器收集器是最基本、历史最悠久的垃圾收集器了。⼤家看名字就知道这个收集器是⼀个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使⽤⼀条垃圾收集线程去完成垃圾收集⼯作,更重要的是它在进⾏垃圾收集⼯作的时候必须暂停其他所有的⼯作线程( “Stop The World” ),直到它收集结束。

新生代采用复制算法,老年代采用标记-整理算法。
Serial收集器
虚拟机的设计者们当然知道Stop The World带来的不良⽤户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。

但是Serial收集器有没有优于其他垃圾收集器的地⽅呢?当然有,它简单⽽⾼效(与其他收集器的单线程相⽐)。Serial收集器由于没有线程交互的开销,⾃然可以获得很⾼的单线程收集效率。Serial收集器对于运⾏在Client模式下的虚拟机来说是个不错的选择。

2. ParNew收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使⽤多线程进⾏垃圾收集外,其余⾏为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全⼀样。

新⽣代采⽤复制算法,⽼年代采⽤标记-整理算法。

ParNew收集器
它是许多运⾏在Server模式下的虚拟机的⾸要选择,除了Serial收集器外,只有它能与CMS收集器(真正意义上的并发收集器,后⾯会介绍到)配合⼯作。

并⾏和并发概念补充:

  • 并行(Parallel) :指多条垃圾收集线程并行工作,但此时⽤户线程仍然处于等待状态。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不⼀定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另⼀个CPU上。

3. Parallel Scavenge收集器

Parallel Scavenge 收集器类似于ParNew 收集器。 那么它有什么特别之处呢?

-XX:+UseParallelGC
 使⽤Parallel收集器+ ⽼年代串⾏
-XX:+UseParallelOldGC
 使⽤Parallel收集器+ ⽼年代并⾏

Parallel Scavenge收集器关注点是吞吐量(⾼效率的利⽤CPU)。CMS等垃圾收集器的关注点更多的是⽤户线程的停顿时间(提⾼⽤户体验)。所谓吞吐量就是CPU中⽤于运⾏⽤户代码的时间与CPU总消耗时间的⽐值。 Parallel Scavenge收集器提供了很多参数供⽤户找到最合适的停顿时间或最⼤吞吐量,如果对于收集器运作不太了解的话,⼿⼯优化存在的话可以选择把内存管理优化交给虚拟机去完成也是⼀个不错的选择。

新⽣代采⽤复制算法,⽼年代采⽤标记-整理算法。

Parallel Scavenge收集器

4. Serial Old收集器

Serial收集器的⽼年代版本,它同样是⼀个单线程收集器。它主要有两⼤⽤途:⼀种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使⽤,另⼀种用途是作为CMS收集器的后备⽅案。

5. Parallel Old收集器

Parallel Scavenge收集器的⽼年代版本。使⽤多线程和“标记-整理”算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器。

6. CMS收集器

CMS(Concurrent Mark Sweep)收集器是⼀种以获取最短回收停顿时间为⽬标的收集器。它而非常符合在注重⽤户体验的应用上使用。

CMS(Concurrent Mark Sweep)收集器是HotSpot虚拟机第⼀款真正意义上的并发收集器,它第⼀次实现了让垃圾收集线程与⽤户线程(基本上)同时⼯作。

从名字中的Mark Sweep这两个词可以看出,CMS收集器是⼀种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂⼀些。整个过程分为四个步骤:

  • 初始标记: 暂停所有系统工作线程,进入STW状态(Stop-The-World),初始标记会标记出来所有GC Roots直接引用的对象,速度很快,对STW影响不大
  • 并发标记: 同时开启系统工作线程和垃圾收集线程一起执行,在这个过程中,系统工作线程会创建新对象,同时对象也会变成垃圾对象。垃圾收集线程会对老年代所有的对象进行GC Roots追踪,对对象层层追踪,直到找到对象是否在根源上被GC Roots引用了,然后进行标记,这个过程最为耗时。但是它是和系统工作线程同时执行的,所以不会产生“Stop the World”。
  • 重新标记: 因为在第二阶段里,一边标记存活对象和垃圾对象,一边系统不停的运行创建新对象,让老对象变成垃圾,所以在第二阶段结束后,绝对会有很多存活对象和垃圾对象未被标记。所以在第三阶段,要让系统再次停下,进入“Stop the World”状态。然后重新标记在第二阶段里创建的一些失去引用的对象,这些失去引用的对象不多,因此标记速度很快,对“Stop the World”影响不大。
  • 并发清除: 统程序垃圾收集程序同时运行,垃圾收集程序知识对前面标记出来的垃圾对象进行清理即可。清理的阶段时很耗时的,但是他和系统程序并发执行,因此不会产生“Stop the World”,不影响系统程序运行。

CMS收集器
从它的名字就可以看出它是⼀款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下⾯三个明显的缺点:

  • 对CPU资源敏感;
  • 无法处理浮动垃圾;
  • 它使⽤的回收算法-“标记-清除”算法会导致收集结束时会有⼤量空间碎片产⽣。

7. G1收集器

G1 (Garbage-First)是⼀款⾯向服务器的垃圾收集器,主要针对配备多颗处理器及⼤容量内存的机器。以极⾼概率满⾜GC停顿时间要求的同时,还具备⾼吞吐量性能特征。

被视为JDK1.7中HotSpot虚拟机的⼀个重要进化特征。它具备⼀下特点:

  • 并行与并发:G1能充分利⽤CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。
  • 分代收集:虽然G1可以不需要其他收集器配合就能独⽴管理整个GC堆,但是还是保留了分代的概念。
  • 空间整合:与CMS的“标记–清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另⼀个⼤优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建⽴可预测的停顿时间模型,能让使⽤者明确指定在⼀个⻓度为 M 毫秒的时间⽚段内。

G1收集器的运作⼤致分为以下⼏个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

G1收集器在后台维护了⼀个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来)。这种使⽤Region划分内存空间以及有优先级的区域回收⽅式,保证了GF收集器在有限时间内可以尽可能⾼的收集效率(把内存化整为零)。

十六.类文件结构

根据 Java 虚拟机规范,类⽂件由单个 ClassFile 结构组成:

ClassFile {
	u4 magic; //Class ⽂件的标志
 	u2 minor_version; //Class 的⼩版本号
 	u2 major_version; //Class 的⼤版本号
 	u2 constant_pool_count; //常量池的数量
 	cp_info constant_pool[constant_pool_count-1]; //常量池
 	u2 access_flags; //Class 的访问标记
 	u2 this_class; //当前类
 	u2 super_class; //⽗类
 	u2 interfaces_count; //接⼝
 	u2 interfaces[interfaces_count]; //⼀个类可以实现多个接⼝
 	u2 fields_count; //Class ⽂件的字段属性
 	field_info fields[fields_count]; //⼀个类会可以有个字段
 	u2 methods_count; //Class ⽂件的⽅法数量
 	method_info methods[methods_count]; //⼀个类可以有个多个⽅法
 	u2 attributes_count; //此类的属性表中的属性数
 	attribute_info attributes[attributes_count]; //属性表集合
}

Class ⽂件字节码结构组织示意图
class 文件字节码结构
下⾯会按照上图结构按顺序详细介绍⼀下 Class ⽂件结构涉及到的⼀些组件。

  1. 魔数: 确定这个⽂件是否为⼀个能被虚拟机接收的 Class ⽂件。
  2. Class ⽂件版本 :Class ⽂件的版本号,保证编译正常执⾏。
  3. 常量池 :常量池主要存放两⼤常量:字⾯量和符号引⽤。
  4. 访问标志 :标志⽤于识别⼀些类或者接⼝层次的访问信息,包括:这个Class 是类还是接⼝,是否为 public 或者 abstract 类型,如果是类的话是否声明为 final 等等。
  5. 当前类索引,⽗类索引 :类索引⽤于确定这个类的全限定名,⽗类索引⽤于确定这个类的⽗类的全限定名,由于 Java 语⾔的单继承,所以⽗类索引只有⼀个,除了 java.lang.Object 之外,所有的 java 类都有⽗类,因此除了java.lang.Object 外,所有 Java 类的⽗类索引都不为 0。
  6. 接⼝索引集合 :接⼝索引集合⽤来描述这个类实现了那些接⼝,这些被实现的接⼝将按 implents (如果这个类本身是接⼝的话则是 extends ) 后的接⼝顺序从左到右排列在接⼝索引集合中。
  7. 字段表集合 :描述接⼝或类中声明的变量。字段包括类级变量以及实例变量,但不包括在⽅法内部声明的局部变量。
  8. ⽅法表集合 :类中的⽅法。
  9. 属性表集合 : 在 Class ⽂件,字段表,方法表中都可以携带⾃⼰的属性表集合。

十七.类加载过程

1. 类加载过程

类加载过程

  1. 加载
    它是 Java 将字节码数据从不同的数据源读取到 JVM 中,并映射为 JVM 认可的数据结构(Class 对象),这里的数据源可能是各种各样的形态,比如jar 文件,class 文件,甚至是网络数据源等;如果输入数据不是 ClassFile 的结构,则会抛出 ClassFormatError。加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程。

  2. 连接
    这是核心的步骤,简单说是把原始的类定义信息平滑地转入 JVM 运行的过程中。这里可进一步细分成三个步骤:

    • 验证(Verification): 这是虚拟机安全的重要保障,JVM 需要核验字节信息是符合 Java 虚拟机规范的,否则就被认为是 VerifyError,这样就防止了恶意信息或者不合规信息危害 JVM 的运行,验证阶段有可能触发更多 class的加载。
    • 准备(Pereparation): 创建类或者接口中的静态变量,并初始化静态变量的初始值。但这里的“初始化”和下面的显示初始化阶段是有区别的,侧重点在于分配所需要的内存空间,不会去执行更进一步的 JVM 指令。
    • 解析(Resolution): 在这一步会将常量池中的符号引用(symbolic reference)替换为直接引用。在 Java 虚拟机规范中,详细介绍了类,接口,方法和字段等各方面的解析。
  3. 初始化
    这一步真正去执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑。再来谈谈双亲委派模型,简单说就是当加载器(Class-Loader)试图加载某个类型的时候,除非父类加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载 Java 类型。

2. 类加载器

JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承⾃ java.lang.ClassLoader :

  1. BootstrapClassLoader(启动类加载器) :最顶层的加载类,由C++实现,负责加载%JAVA_HOME%/lib ⽬录下的jar包和类或者或被 -Xbootclasspath 参数指定的路径中的所有类。
  2. ExtensionClassLoader(扩展类加载器) :主要负责加载⽬录 %JRE_HOME%/lib/ext ⽬录下的jar包和类,或被 java.ext.dirs 系统变量所指定的路径下的jar包。
  3. AppClassLoader(应⽤程序类加载器) :⾯向我们⽤户的加载器,负责加载当前应⽤classpath下的所有jar包和类。

3. 双亲委派模型

  1. 概述
    每⼀个类都有⼀个对应它的类加载器。系统中的 ClassLoder 在协同⼯作的时候会默认使⽤ 双亲委派模型 。即在类加载的时候,系统会⾸先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,⾸先会把该请求委派该⽗类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当⽗类加载器⽆法处理时,才由⾃⼰来处理。当⽗类加载器为null时,会使⽤启动类加载器 BootstrapClassLoader 作为父类加载器。

双亲委派
每个类加载都有⼀个⽗类加载器,我们通过下⾯的程序来验证。

public class ClassLoaderDemo {
	public static void main(String[] args) {
		System.out.println("ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader());
		System.out.println("The Parent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent());
		System.out.println("The GrandParent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent().getParent());
	}
}

Output

ClassLodarDemo's ClassLoader is
sun.misc.Launcher$AppClassLoader@18b4aac2
The Parent of ClassLodarDemo's ClassLoader is
sun.misc.Launcher$ExtClassLoader@1b6d3586
The GrandParent of ClassLodarDemo's ClassLoader is null

AppClassLoader 的⽗类加载器为 ExtClassLoader
ExtClassLoader 的⽗类加载器为null,null并不代表 ExtClassLoader 没有⽗类加载器,而是 Bootstrap ClassLoader 。

  1. 源码分析
    双亲委派模型的实现代码⾮常简单,逻辑⾮常清晰,都集中在java.lang.ClassLoader 的loadClass() 中,相关代码如下所示。
private final ClassLoader parent;
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
	synchronized (getClassLoadingLock(name)) {
		// 首先,检查请求的类是否已经被加载过
		Class<?> c = findLoadedClass(name);
		if (c == null) {
			long t0 = System.nanoTime();
			try {
				if (parent != null) {
					//父加载器不为空,调用父加载器 loadClass() ⽅法处理
					c = parent.loadClass(name, false);
				} else {
					//父加载器为空,使用启动类加载器 BootstrapClassLoader 加载
					c = findBootstrapClassOrNull(name);
				}
			} catch (ClassNotFoundException e) {
				//抛出异常说明⽗类加载器⽆法完成加载请求
			}
	 
			if (c == null) {
				long t1 = System.nanoTime();
				//自己尝试加载
				c = findClass(name);
				// this is the defining class loader; record the stats
	 			sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
				sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
				sun.misc.PerfCounter.getFindClasses().increment();
			}
		}
		if (resolve) {
			resolveClass(c);
		}
		return c;
	}
}
  1. 双亲委派模型的好处
    双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的⽅式不仅仅根据类名,相同的类⽂件被不同的类加载器加载产⽣的是两个不同的类),也保证了 Java 的核心 API 不被篡改。如果不用没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现⼀些问题,比如我们编写⼀个称为 java.lang.Object 类的话,那么程序运⾏的时候,系统就会出现多个不同的Object 类

  2. 如何避免双亲委托机制
    为了避免双亲委托机制,我们可以⾃⼰定义⼀个类加载器,然后重载 loadClass() 即可。

  3. 如何⾃定义类加载器
    除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承⾃java.lang.ClassLoader 。如果我们要⾃定义⾃⼰的类加载器,很明显需要继承 ClassLoader 。

参考: 傑0327bruce128温驭臣JavaGuide面试突击

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值