Java面试知识点总结-JVM

Java内存区域

Java和C++在GC上的区别

对于 Java 程序员,在虚拟机自动内存管理机制下,不再需要像 C/C++ 程序开发程序员这样为每一个 malloc/new 操作去写对应的 free/delete 操作,且不易出现内存泄漏和内存溢出问题。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。

Java不同版本中运行时数据区域的区别

图片说明

线程私有部分:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享部分:

  • 方法区
  • 直接内存

方法区随着 JDK 版本变化发生了移动,详细变化可参考后面的内容。

程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

Java虚拟机栈

与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是Java方法执行的内存模型,每次方法调用的数据都是通过栈传递的。Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。

图片说明

局部变量表主要存放了编译期可知的各种数据类型(boolean,byte,char,short,int,float,long,double)以及对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。long 和 double 会占用两个局部变量空间(slot),局部变量表所需内存空间在编译期完成分配。当进入一个方法时,表示其的栈帧的大小是确定的,在方法运行期间不会改变局部变量表的大小。

Java 程序编译之后,会得到程序中每一个类或者接口的独立 class 文件。虽然看上去毫无关联,但是他们之间通过接口(harbor)符号互相联系,或者与Java API的class文件相联系。在 C/C++ 中静态链接在编译期将所有类加载并找到他们的直接引用,无论是否会被用到。如果方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的,则具有编译期可知、运行期不可变的特点。符合上述条件的方法主要包括静态方法和私有方法两大类。前者与类型直接关联,后者在外部不可被访问,这两种方法的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们适合在类加载阶段进行解析。在 Java 虚拟机中提供了 5 条方法调用字节码指令,其中 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、构造器、父类方法 4 类。它们在类加载的时候就会把符号引用解析为该方法的直接引用。解析调用一定是个静态过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用转化为可确定的直接引用,不会延迟到运行期再去完成,这也就是Java中的静态链接。在 Class 文件中的常量持中存有大量的符号引用。字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分在类的加载阶段(解析)或第一次使用的时候就转化为了直接引用(指向数据所存地址的指针或句柄等),这种转化称为静态链接。而相反的,另一部分在运行期间转化为直接引用,就称为动态链接。

Java虚拟机栈会出现两种异常:

  • 当线程请求的栈深度超过最大值,会抛出 StackOverflowError 异常;
  • 栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常。

本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。

Java 虚拟机所管理的内存中最大的一块,Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存(这句话意味着并非所有对象都是在堆上分配,在引用 JIT 即时编译技术后,基于栈上分配和标量替换技术可以使得对象不在堆上)。Java堆是垃圾收集器管理的主要区域,因此也被称作GC堆。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。在 JDK8 中, 新生代约占整个堆 1/3 的空间(Eden:from:to = 8:1:1),其他 2/3 的空间分配给老年代。

图片说明

在 JDK7 及其更老的版本中,堆内存通常被分为新生代、老生代以及永生代。JDK8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。堆最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

  • OutOfMemoryError: GC Overhead Limit Exceeded : 当JVM花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误
  • java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发java.lang.OutOfMemoryError: Java heap space 错误。(和本机物理内存无关,和你配置的内存大小有关!)

拓展阅读:
逃逸分析相关1
逃逸分析相关2

方法区

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。JDK8 中,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。

方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。

下面给出随 JDK 版本变更的方法区:

JDK6

  • Klass 元数据信息
  • 每个类的运行时常量池(字段、方法、类、接口等符号引用)、编译后的代码
  • 静态字段(无论是否有final)在 instanceKlass 末尾(位于 PermGen 内)
  • oop(Ordinary Object Pointer(普通对象指针))其实就是 Class 对象实例
  • 全局字符串常量池 StringTable,本质上就是个 Hashtable
  • 符号引用(类型指针是 SymbolKlass)

JDK7

  • Klass 元数据信息
  • 每个类的运行时常量池(字段、方法、类、接口等符号引用)、编译后的代码
  • 静态字段从 instanceKlass 末尾移动到了 java.lang.Class 对象(oop)的末尾(位于 Java Heap 内)
  • oop 与全局字符串常量池移到 Java Heap 上
  • 符号引用被移动到 Native Heap 中

JDK8

  • 移除永久代
  • Klass 元数据信息
  • 每个类的运行时常量池、编译后的代码移到了另一块与堆不相连的本地内存 -- 元空间(Metaspace)

运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)。JDK7 及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。

图片说明

下面简单介绍下字面量和符号引用。字面量可以理解为实际值,比如 int a = 8String a = "hello"。符号引用通常是一个字符串,只要在代码中引用了一个非字面量的东西,不管它是变量还是常量,它都只是由一个字符串定义的符号,这个字符串存在常量池里,类加载的时候第一次加载到这个符号时,就会将这个符号引用(字符串)解析成直接引用(指针)。比如某个方法的符号引用,“java/io/PrintStream.println:(Ljava/lang/String;)V”。里面有类的信息,方法名,方法参数等信息。

符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

而直接引用和虚拟机的布局相关,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。直接引用可以有不同的实现方式:

  • 直接指向目标的指针(比如,指向Class对象、类变量、类方法的直接引用可能是指向方法区的指针)
  • 相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)
  • 一个能间接定位到目标的句柄

直接内存

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

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

类加载过程

类的生命周期

Class 文件需要加载到虚拟机中之后才能运行和使用,系统加载 Class 类型的文件主要三步:加载 -> 连接 -> 初始化。连接过程又可分为三步:验证 -> 准备 -> 解析。

图片说明

加载

类加载过程的第一步,主要完成下面3件事情:

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

其中二进制字节流可以从以下方式中获取:

  • 从 ZIP 包读取,成为 JAR、EAR、WAR 格式的基础
  • 从网络中获取,最典型的应用是 Applet
  • 运行时计算生成,例如动态***技术,在 java.lang.reflect.Proxy 使用
  • ProxyGenerator.generateProxyClass 的***类的二进制字节流
  • 由其他文件生成,例如由 JSP 文件生成对应的 Class 类

一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass()方法)。数组类型不通过类加载器创建,它由 Java 虚拟机直接创建。加载阶段和连接阶段的部分内容是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。

连接

验证

先进行验证,确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。主要包含文件格式验证,元数据验证,字节码验证和符号引用验证。
图片说明
文件格式验证:验证字节流是否符合Class文件格式的规范,而且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:

  • 是否以0xCAFEBABE开头
  • 主、次版本号是否在当前虚拟机的处理范围之内
  • 常量池中的常量是否有不被支持的常量类型
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
  • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息

该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区内,格式上符合描述一个Java类型信息的要求。该阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面的3个验证阶段都是基于方法区的存储结构进行的,不会再直接操作字节流。

元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,这个阶段可能包括的验证点:

  • 这个类是否有除了java.lang.Object之外的父类
  • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)
  • 如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法
  • 类中的字段、方法是否与父类产生矛盾

字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,例如:

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作
  • 保证跳转指令不会跳转到方法体以外的字节码指令上
  • 保证方法体中的类型转换是有效的

符号引用验证:最后一个阶段的校验发生在解析阶段,其对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要校验以下内容:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
  • 符号引用中的类、字段、方法的访问性是否可被当前类访问

符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出异常。该阶段是一个非常重要的、但不是一定必要的阶段。

图片说明

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:

  • 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中
  • 所设置的初始值"通常情况"下是`数据类型默认的零值(如0、0L、null、false等),特殊情况:比如给 value 变量加上了 fianl 关键字public static final int value=111 ,那么准备阶段 value 的值就被复制为 111
  • 对于同时被 static 和 final 修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过
  • 对于引用数据类型来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null
  • 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。例如常量“a”会被替换为内存中的地址。解析动作主要针对接口字段类方法接口方法方法类型方法句柄调用限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定。

图片说明

解析详细介绍

初始化

初始化是类加载的最后一步,也是真正执行类中定义的Java程序代码(字节码),初始化阶段是执行类构造器 <clinit>() 方法的过程。对于<clinit>()方法的调用,虚拟机会自己确保其在多线程环境中的安全性。

图片说明

对于初始化阶段,虚拟机严格规范了有且只有 5 种情况下,必须对类进行初始化:

  • 当遇到 new、 getstatic、 putstatic 或 invokestatic 这4条直接码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时
  • 使用 java.lang.reflect 包的方法对类进行反射调用时 ,如果类没初始化,需要触发其初始化
  • 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化
  • 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类

对象创建过程

类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 指针碰撞空闲列表 两种,选择那种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

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

  • CAS + 失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性
  • TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配

初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式

执行init方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来</init></init>

对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充。结构如下图:

图片说明

Hotspot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的自身运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。如果对象是一个数组,那么对象头中还应该有字段用于记录数组长度。实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。

在 32 位的 HotSpot 虚拟机中,如果对象处于未被锁定的状态下,那么 Mark Word 的 32bit 空间中的 25bit 用于存储对象哈希码,4bit 用于存储对象分代年龄,2bit 用于存储锁标志位,1bit 固定为 0。

图片说明

在 32 位系统下,存放 Class 指针的空间大小是 4 字节,Mark Word 空间大小也是4字节,因此头部就是 8 字节,如果是数组就需要再加 4 字节表示数组的长度。
图片说明

在 64 位系统及 64 位 JVM 下,开启指针压缩,那么头部存放 Class 指针的空间大小还是4字节,而 Mark Word 区域会变大,变成 8 字节,也就是头部最少为 12 字节。
图片说明

对象内存布局拓展阅读

对象的访问定位方式

对象的访问方式由虚拟机实现而定,目前主流的访问方式有句柄和直接指针两种。

  • 句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据类型数据各自的具体地址信息
    图片说明

  • 直接指针: 如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象的地址
    图片说明

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

垃圾回收机制

内存分配和回收

Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是 内存中对象的分配与回收。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
图片说明

常见的分配与回收策略如下:

  • 对象优先在 eden 区分配
  • 大对象直接进入老年代(需要大量连续内存空间的对象,比如:字符串、数组)
  • 长期存活的对象将进入老年代

大多数情况下,对象在新生代中 eden 区分配。当 eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。为了更好的适应不同程序的内存情况,虚拟机不是永远要求对象年龄必须达到了某个值才能进入老年代,如果 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需达到要求的年龄。

各区所占的比例如下,Tentired *占 2/3,剩下的 1/3 *Eden占80%,两个survivor各占 10% 。

大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。

如何判断对象已经死亡

引用计数法给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。所谓对象之间的相互引用问题,如下面代码所示:除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。

public class ReferenceCountingGc {
    Object instance = null;
    public static void main(String[] args) {
        ReferenceCountingGc objA = new ReferenceCountingGc();
        ReferenceCountingGc objB = new ReferenceCountingGc();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;

    }
}

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

图片说明

在 Java 中,可作为 GC Root 的对象通常包括下面几种:

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

即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize *方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。被判定为需要执行的对象将会被放在一个队列 *F-Queue 中,稍后由一个虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。如果在 finalize **方法中出现死循环,则可能导致内存回收系统崩溃。finalize 方法使得对象有一次逃离死亡的机会,只要这个对象与引用链上的任何一个对象建立关联即可,否则就会被真的回收。任何对象的finalize **方法都只会被系统自动调用一次。

如何判断变量和类无用?

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

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类” :

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

Java中的几种引用

引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱):

  • 强引用,常见的引用,垃圾回收器绝不会回收它。当内存空 间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题
  • 软引用:如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。软引用适合缓存的场景。
  • 弱引用:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。除了WeakHashMap使用了弱引用,ThreadLocal类中也是用了弱引用。
  • 虚引用:虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。虚引用唯一的用处在于对象被回收器回收时收到一个系统通知。

SafePoint与SafeRegion

safepoint 可以用在不同地方,比如 GC、 Deoptimization ,在 Hotspot VM 中,GC safepoint 比较常见,需要一个数据结构记录每个线程的调用栈、寄存器等一些重要的数据区域里什么地方包含了 GC 管理的指针。从线程角度看,safepoint 可以理解成是在代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,说明虚拟机当前的状态是安全的,如果有需要,可以在这个位置暂停,比如发生 GC 时,需要暂停暂停所以活动线程,但是线程在这个时刻,还没有执行到一个安全点,所以该线程应该继续执行,到达下一个安全点的时候暂停,等待 GC 结束。safepoint指的特定位置主要有:

  • 循环的末尾 (防止大循环的时候一直不进入safepoint,而其他线程在等待它进入safepoint)
  • 方法返回前
  • 调用方法的call之后
  • 抛出异常的位置

之所以选择这些位置作为 safepoint 的插入点,主要的考虑是“避免程序长时间运行而不进入safepoint”,比如 GC 的时候必须要等到 Java 线程都进入到 safepoint 的时候 VM Thread 才能开始执行 GC ,如果程序长时间运行而没有进入 safepoint ,那么 GC 也无法开始,JVM 可能进入到 Freezen 假死状态。在 stackoverflow 上有人提到过一个问题,由于 BigInteger 的 pow 执行时 JVM 没有插入 safepoint ,导致大量运算时线程一直无法进入 safepoint,而 GC 线程也在等待这个 Java 线程进入 safepoint 才能开始 GC,结果 JVM 就 Freezen 了。

SafeRegion 是指在一段代码片段中,引用关系不会发生变化。在这个区域中的任意地方开始 GC 都是安全的。可以吧 SafeRegion 看做被扩展了的 SafePoint 。在线程执行到SafeRegion 时,首先标识自己进入了 SafeRegion ,在这段时间内如果 JVM 要发起 GC ,就不用管表示自己为 SafeRegion 状态的线程了。在线程要离开 SafeRegion 时,它要检查系统是否已完成了根节点枚举(或者整个GC过程),如果完成了那线程就继续执行,否则必须等待直到收到可以安全离开 SafeRegion 的信号为止。

OopMap

SafePoint 与 SafeRegion 定义了何时进行可达性分析, OopMap 则是加速可达性分析的一种手段。这里对 GC 的基本分类再进行适当介绍。

如果 JVM 不记录任何类型的数据,那么就无法区分内存某个位置上的数据到底是引用类型、整型或其他基本类型。这种条件下的 GC 被称为是 保守式GC。在进行 GC 的时候,JVM会从一些已知位置开始(例如说JVM栈)扫描内存,对于每隔扫描到的数字判断是否是执行 GC 堆的指针。这个过程涉及上下边界检查(GC堆的上下界已知)以及对齐检查(能被4整除的数字一定不是指针)。这种扫描方式存在一定问题,比如存在数字的值和一个地址的值相同,这样这个地址上的对象本来应该被回收,但是由于这个栈的数字巧合的和它的地址值相同,导致这个对象不会被回收。保守式 GC 的好处是相对来说实现简单些,而且可以方便的用在对 GC 没有特别支持的编程语言里提供自动内存管理功能。保守式GC的缺点有:

  • 会有部分对象本来应该已经死了,但有疑似指针指向它们,使它们逃过GC的收集
  • 由于不知道疑似指针是否真的是指针,所以它们的值都不能改写;移动对象就意味着要修正指针,换言之,对象就不可移动了。虽然可以使用句柄池迂回解决问题,但是二次访问的开销有时访问速度

当然,JVM 可以在对象上记录类型信息。这样扫描到 GC 堆内的对象时因为对象带有足够类型信息,JVM 就能够判断出在该对象内什么位置(偏移)的数据是否是引用类型。这种是方式被称之为半保守式GC,也称为根上保守。本质上来说,这是一种简单的查表方式,有利于提高枚举根节点的速度并允许对象移动。为了支持半保守式 GC ,运行时需要在对象上带有足够的元数据。对于 JVM 而言,这些数据可能在类加载器或者对象模型的模块里计算得到,但不需要 JIT 编译器的特别支持。

准确式GC,意味着给定内存某个数据,能准确知道其是否是引用类型。要实现这样的 GC ,JVM 就要能够判断出所有位置上的数据是不是指向 GC 堆里的引用,包括活动记录(栈+寄存器)里的数据。主流 JVM 采用从外部记录类型信息并生成映射表,HotSpot 管这样的数据结构叫做 OopMap 。在解释执行时或 JIT 时,记录下栈上某个数据对应的数据类型,比如地址1上的值12344是一个堆上地址引用,数据类型为 com.aaaa.aaa.AAA 。使用这样的映射表一般有两种方式:

  • 每次都遍历原始的映射表,循环的一个个偏移量扫描过去;这种用法也叫解释式
  • 为每个映射表生成一块定制的扫描代码,以后每次要用映射表就直接执行生成的扫描代码;这种用法也叫编译式

在 HotSpot 中,对象的类型信息里有记录自己的 OopMap ,记录了在该类型的对象内什么偏移量上是什么类型的数据。所以从对象开始向外的扫描可以是准确的;这些数据是在类加载过程中计算得到的。可以把 oopMap 简单理解成是调试信息。 在源代码里面每个变量都是有类型的,但是编译之后的代码就只有变量在栈上的位置了。oopMap 就是一个附加的信息,告诉你栈上哪个位置本来是什么类型的变量。这个信息是在 JIT 编译时跟机器码一起产生的。因为只有编译器知道源代码跟产生的代码的对应关系。每个方法可能会有若干个 oopMap ,safepoint 把一个方法的代码分成若干段,每一段代码一个 oopMap ,所以 oopMap 的作用域自然也仅限于这一段代码。循环中引用多个对象,意味着会有多个变量,编译后占据栈上的多个位置。那这段代码的 oopMap 就会包含多条记录。每个被 JIT 编译过后的方法也会在一些特定的位置记录下 OopMap ,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。这样 GC 在扫描栈的时候就会查询这些 OopMap 就知道哪里是引用了。这些特定的位置主要在:

  • 循环的末尾
  • 方法临返回前 / 调用方法的call指令后
  • 可能抛异常的位置

这种位置被称为安全点。之所以要选择一些特定的位置来记录 OopMap ,是因为如果对每条指令(的位置)都记录 OopMap 的话,开销过大。

垃圾收集算法

标记清除算法

该算法分为“标记”和“清除”阶段:首先标记出所有存活的对象,在标记完成后统一回收所有未被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算***带来两个明显的问题:

  • 效率问题
  • 空间问题(标记清除后会产生大量不连续的碎片)

图片说明

复制算法

为了解决效率问题,“复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

图片说明

标记-整理算法

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

图片说明

分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法,可以根据各个年代的特点选择合适的垃圾收集算法。比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

垃圾收集器

图片说明

Serial 收集器

Serial(串行)收集器收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。新生代采用复制算法,老年代采用标记-整理算法。

ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。新生代采用复制算法,老年代采用标记-整理算法。它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。

Parallel Scavenge 收集器

Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。新生代采用复制算法,老年代采用标记-整理算法。

Serial Old 收集器

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

Parallel Old 收集器

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

CMS 收集器

CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
  • 并发清除: 开启用户线程,同时 GC 线程开始对为标记的区域做清扫

CMS的缺点:

  • 对 CPU 资源敏感(CMS默认启动的回收线程数是(cpu数量+3/4))
  • 无法处理浮动垃圾
  • 它使用标记清除算导致收集结束时会有大量空间碎片产生

CMS 收集器无法处理浮动垃圾,可能出现 “Concurrent Mode Failure” 失败而导致另一次Full GC 的产生。由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后, CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

G1收集器

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。它具备以下特定:

  • 并行与并发: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 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

G1 的内存结构和传统的内存空间划分有比较的不同。G1 将内存划分成了多个大小相等的Region(默认是512K),Region 逻辑上连续,物理内存地址不连续。同时每个 Region 被标记成E、S、O、H,分别表示Eden、Survivor、Old、Humongous。其中E、S属于年轻代,O与H属于老年代。示意图如下:
图片说明
H表示Humongous。从字面上就可以理解表示大的对象(下面简称H对象)。当分配的对象大于等于 Region 大小的一半的时候就会被认为是巨型对象。H 对象默认分配在老年代,可以防止 GC 的时候大对象的内存拷贝。通过如果发现堆内存容不下 H 对象的时候,会触发一次 GC 操作。

通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。

在进行 Young GC 的时候,Young 区的对象可能还存在 Old 区的引用, 这就是跨代引用的问题。为了解决 Young GC 的时候,扫描整个老年代,G1 引入了 Card Table 和 Remembered Set 的概念,基本思想就是用空间换时间。这两个数据结构是专门用来处理 Old 区到 Young 区的引用。Young 区到 Old 区的引用则不需要单独处理,因为 Young 区中的对象本身变化比较大,没必要浪费空间去记录下来:

  • RSet:全称Remembered Sets, 用来记录外部指向本Region的所有引用,每个Region维护一个RSet。
  • Card: JVM将内存划分成了固定大小的Card。这里可以类比物理内存上page的概念。

SATB 的全称(Snapshot At The Beginning)字面意思是开始GC前存活对象的一个快照。SATB的作用是保证在并发标记阶段的正确性。当并发标记阶段,应用线程改变了引用关系,则会破坏快照的准确性。G1 采用的是 pre-write barrier 解决这个问题。简单说就是在并发标记阶段,当引用关系发生变化的时候,通过 pre-write barrier 函数会把这种这种变化记录并保存在一个队列里,在 JVM 源码中这个队列叫 satb_mark_queue 。在 remark 阶段会扫描这个队列,通过这种方式,旧的引用所指向的对象就会被标记上,其子孙也会被递归标记上,这样就不会漏标记任何对象, snapshot 的完整性也就得到了保证。SATB 的方式记录活对象,也就是那一时刻对象snapshot, 但是在之后这里面的对象可能会变成垃圾, 叫做浮动垃圾(floating garbage),这种对象只能等到下一次收集回收掉。在 GC 过程中新分配的对象都当做是活的,其他不可达的对象就是死的。

内存分配策略

  • 对象优先在 Eden 分配
  • 大对象直接进入老年代
  • 长期存活的对象进入老年代
  • 动态对象年龄判定(虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。)
  • 空间分配担保(在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC)

Minor GC与Major GC与Full GC

Minor GC

从年轻代空间(包括 Eden 和 Survivor 区域)回收内存被称为 Minor GC。这一定义既清晰又易于理解。但是,当发生Minor GC事件的时候,有一些有趣的地方需要注意到:

  • 当 JVM 无法为一个新的对象分配空间时会触发 Minor GC,比如当 Eden 区满了。所以分配率越高,越频繁执行 Minor GC
  • 内存池被填满的时候,其中的内容全部会被复制,指针会从 0 开始跟踪空闲内存。Eden 和 Survivor 区进行了标记和复制操作,取代了经典的标记、扫描、压缩、清理操作。所以 Eden 和 Survivor 区不存在内存碎片。写指针总是停留在所使用内存池的顶部
  • 执行 Minor GC 操作时,不会影响到永久代。从永久代到年轻代的引用被当成 GC roots,从年轻代到永久代的引用在标记阶段被直接忽略掉
  • 质疑常规的认知,所有的 Minor GC 都会触发"全世界的暂停(stop-the-world)",停止应用程序的线程。对于大部分应用程序,停顿导致的延迟都是可以忽略不计的。其中的真相就是,大部分 Eden 区中的对象都能被认为是垃圾,永远也不会被复制到 Survivor 区或者老年代空间。如果正好相反,Eden 区大部分新生对象不符合 GC 条件,Minor GC 执行时暂停的时间将会长很多。

Full GC

Full GC 的触发条件:

  • 调用 System.gc()
  • 老年代空间不足,常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等
  • 空间分配担保失败,使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC
  • JDK 1.7 及以前的永久代空间不足

这里补充一下与 System.gc() 相关的内容:
当调用System.gc()的时候,其实并不会马上进行垃圾回收,甚至不一定会执行垃圾回收。

public static void gc() {
    boolean shouldRunGC;
    synchronized(lock) {
        shouldRunGC = justRanFinalization;
        if (shouldRunGC) {
            justRanFinalization = false;
        } else {
            runGC = true;
        }
    }
    if (shouldRunGC) {
        Runtime.getRuntime().gc();
    }
}

也就是 justRanFinalization=true 的时候才会执行。查找发现当调用 runFinalization()的时候 justRanFinalization 变为true。

public static void runFinalization() {
    boolean shouldRunGC;
    synchronized(lock) {
        shouldRunGC = runGC;
        runGC = false;
    }
    if (shouldRunGC) {
        Runtime.getRuntime().gc();
    }
    Runtime.getRuntime().runFinalization();
    synchronized(lock) {
        justRanFinalization = true;
    }
}

直接调用 System.gc() 只会把这次 gc 请求记录下来,等到 runFinalization=true 的时候才会先去执行 GC,runFinalization=true 之后会在允许一次 system.gc()。之后在 call System.gc() 还会重复上面的行为。所以 System.gc() 要跟 System.runFinalization() 一起搭配使用:

static void gcAndFinalize() {
    final VMRuntime runtime = VMRuntime.getRuntime();
    System.gc();
    runtime.runFinalizationSync();
    System.gc();
}

java.lang.System.gc()只是java.lang.Runtime.getRuntime().gc()的简写,两者的行为没有任何不同。唯一的区别就是System.gc()写起来比Runtime.getRuntime().gc()简单点。GC本身是会周期性的自动运行的,由JVM决定运行的时机,而且现在的版本有多种更智能的模式可以选择,还会根据运行的机器自动去做选择,就算真的有性能上的需求,也应该去对GC的运行机制进行微调,而不是通过使用这个命令来实现性能的优化。

Major GC vs Full GC

  • Major GC 是清理老年代
  • Full GC 是清理整个堆空间—包括年轻代和老年代
  • 执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足

很不幸,实际上它还有点复杂且令人困惑。首先,许多 Major GC 是由 Minor GC 触发的,所以很多情况下将这两种 GC 分离是不太可能的。另一方面,许多现代垃圾收集机制会清理部分永久代空间,所以使用“cleaning”一词只是部分正确。

GC的分类标准

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

  • Partial GC:并不收集整个GC堆的模式
    • Young GC:只收集young gen的GC
    • Old GC:只收集old gen的GC。只有CMS的concurrent collection是这个模式
    • Mixed GC:收集整个young gen以及部分old gen的GC。只有G1有这个模式
  • Full GC:收集整个堆,包括young gen、old gen、perm gen(如果存在的话)等所有部分的模式

Major GC 通常是跟 Full GC 是等价的,收集整个 GC 堆。但因为 HotSpot VM 发展了这么多年,外界对各种名词的解读已经完全混乱了,当有人说“major GC”的时候一定要问清楚他想要指的是上面的 Full GC 还是 Old GC。

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

  • young GC:当 young gen 中的 ede n区分配满的时候触发。注意 young GC 中有部分存活对象会晋升到 old gen ,所以 young GC 后 old gen 的占用量通常会有所升高。
  • full GC:当准备要触发一次 young GC 时,如果发现之前 young GC 的平均晋升大小比目前 old gen 剩余的空间大,则不会触发 young GC 而是转为触发 full GC(因为HotSpot VM 的 GC 里,除了 CMS 的 concurrent collection 之外,其它能收集 old gen 的 GC 都会同时收集整个 GC 堆,包括 young gen,所以不需要事先触发一次单独的 young GC);或者,如果有 perm gen 的话,要在 perm gen 分配空间但已经没有足够空间时,也要触发一次full GC;或者System.gc()、heap dump带GC,默认也是触发 full GC 。

类加载器

类加载器种类

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

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

双亲委派模型

每一个类都有一个对应它的类加载器。系统中的 ClassLoder 在协同工作的时候会默认使用 双亲委派模型 。即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派该父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为null时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。
图片说明

双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。如果不用没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。所以两个类是否相等,必须建立在它们是由同一个类加载器加载的基础上,否则即使是由同一个VM加载,来自同一个Class文件,也不相等。

被破坏的双亲委派模型

双亲委派模型第一次被破坏出现于双亲委派模型出现之前。JDK1.2为了向前兼容, java.lang.ClassLoader 添加了一个新的 protected 方法 findClass() 。在此之前,用户去继承 java.lang.ClassLoader 的唯一目的就是为了重写 loadClass() 方法。JDK1.2 不建议用户重写 loadClass() 方法,而是应该把自己的类加载逻辑写到 findClass() 中,在 loadClass() 加载失败后再调用 findClass() 完成加载,这样可以避免被破坏。

双亲委派模型的第二次被破坏是由于其本身的性质。如果基础类在被加载时又要调用回用户的代码,启动类加载器将无法识别这些代码。为了解决这个问题,引入了线程上下文加载器,这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader() 方法进行设置,如果创建时还未设置,他将从父线程中继承一个,如果在应用程序的全局范围内都没有设置过,那这个类加载器默认就是应用程序类加载器。

双亲委派模型的第三次被破坏是由于用户对程序动态性追求而导致的,比如代码热替换、模块热部署等。OSGi已经成为了Java模块化标准。每一个程序模块也都有一个类加载器,当需要更换一个Bundle时,就把Bundle连通类加载器一起换掉实现代码的热调换。在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGi将按照下面的顺序进行类搜索:

  • 将以 java.* 开头的类委派给父类加载器加载
  • 否则,将委派列表名单内的类委派给父类加载器加载
  • 否则,将 Import 列表中的类委派给 Export 这个类的 Bundle 的类加载器加载
  • 否则,查找当前 Bundle 的 ClassPath, 使用自己的类加载器加载
  • 否则,查找类是否存在自己的 Fragment Bundle 中,如果在,则委派给 Fragment Bundle 的类加载器加载
  • 否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载
  • 否则,查找失败

变量到底在哪?

方法中的变量

在方法中声明的变量,即该变量是局部变量,每当程序调用方法时,系统都会为该方法建立一个方法栈,其所在方法中声明的变量就放在方法栈中,当方法结束系统会释放方法栈,其对应在该方法中声明的变量随着栈的销毁而结束,这就局部变量只能在方法中有效的原因。

  • 当声明是基本类型的变量的时,其变量名及值(变量名及值是两个概念)是放在JAVA虚拟机栈中
  • 当声明的是引用类型变量时,所声明的变量名(该变量实际上是在方法中存储的是内存地址值)是放在JAVA虚拟机的栈中,该变量所指向的对象是放在堆类存中

类中的变量

在类中声明的变量是成员变量,也叫全局变量,放在堆中,全局变量不会随着某个方法执行结束而销毁。

  • 基本类型的变量,其变量名及其值放在堆内存中
  • 引用类型时,其声明的变量仍然会存储一个内存地址值,该内存地址值指向所引用的对象。引用变量名和对应的对象仍然存储在相应的堆中

静态变量

静态变量只能存在于类中,方法中不允许使用static修饰变量。其生命周期从JVM第一次读到这个类并加载类时开始,类销毁时便不再存在。静态变量存在于方法区即静态区(方法区包含整个程序中唯一存在的元素)

String类与字符串常量池

对下面一段代码进行分析

String s = "aaa" + new String("bbb")

等号左边不创建对象,仅仅是一个对象实例的引用;等号右边才是真正创建的对象。其中,"abc" 并不存在于堆内存中,它存在于常量池中。由于字符串对象可能会被大量引用,Java 为了节省内存空间和运行时间,会把所有字符串放到常量池中保存。常量池中的所有相同字符串仅占一个空间。所以,比较字符串时用 == 时可以的,因为地址唯一。如果常量池中没有字符串 "abc",那么该过程会产生两个对象,分别在 Heap 与方法区的常量池;否则仅 new 产生一个对象,在Heap。

上述代码最多可创造4个对象,分别是:

  • "aaa"一个对象
  • new Sring()一个对象
  • "bbb"一个对象
  • "aa" + new new Sring()一个对象

图片说明

String的创建与位置案例分析

String a1 = "AA";\\只在常量池上创建常量
String a2 = new String("A") + new String("A");//只在堆上创建对象
String a3 = new String("AA");//在堆上创建对象,在常量池上创建常量
String a4 = new String("A") + new String("A");//只在堆上创建对象AA
a4.intern();//将该对象AA的引用保存到常量池上
String a5 = new String("A") + new String("A");//只在堆上创建对象
a5.intern();//在常量池上创建引用
String a6 = "AA";//此时不会再在常量池上创建常量AA,而是将a5的引用返回给a6
System.out.println(a5 == a6); //true

字符串常量池

JVM 为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化:

  • 为字符串开辟一个字符串常量池,类似于缓存区
  • 创建字符串常量时,首先坚持字符串常量池是否存在该字符串,存在该字符串,返回引用实例,不存在,实例化该字符串并放入池中

字符串常量池随JDK版本的位置变化:

  • 7 以前方法区位于永久代(PermGen),永久代和堆相互隔离,永久代的大小在启动 JVM 时可以设置一个固定值,不可变
  • 7 时存储在永久代的部分数据就已经转移到 Java Heap 或者 Native memory。但永久代仍存在于 JDK 1.7中,并没有完全移除,譬如符号引用(Symbols)转移到了native memory;字符串常量池(interned strings)转移到了Java heap;类的静态变量(class statics)转移到了Java heap
  • 8 中取消永久代,其余的数据作为元数据存储在元空间中存放于元空间(Metaspace), 元空间是一块与堆不相连的本地内存。
全部评论

相关推荐

TP-LINK 前端工程师 年包大概20出头 本科
点赞 评论 收藏
转发
7 4 评论
分享
牛客网
牛客企业服务