目录
一、JVM的主要组成部分及其作用
二、运行时数据区/运行时内存区域
三、堆、栈
四、JAVA对象大小的计算
五、对象的访问定位的两种方式
六、判断垃圾可以回收的方法/如何判断对象是否死亡
七、垃圾回收从哪里开始
八、被标记为垃圾的对象一定会被回收吗
九、JAVA中四种引用类型
十、内存泄漏、内存溢出
十一、常用的垃圾收集算法
十二、分代收集算法
十三、浮动垃圾
十四、内存碎片
十五、常用的垃圾收集器
十六、CMS
十七、G1
十八、垃圾回收策略、垃圾回收时机
十九、内存分配
二十、类文件结构、类加载机制、类加载器、类与类加载器的关系、双亲委派模式、3种类加载器
二十一、JAVA内存模型、内存间交互
二十二、JAVA对象的创建过程
二十三、JVM内存分配与回收、堆内存中对象分配的基本策略
二十四、如何判断一个常量是废弃常量、如何判断一个类是无用类
二十五、哪些对象可以作为GC roots
二十六、Class.forName() 和 ClassLoader()区别
二十七、如何利用监控工具调优?
二十八、JVM一些参数
二十九、Java对象头
一、JVM的主要组成部分及其作用
1. 类加载器(
ClassLoader
)
2. 运行时数据区(
Runtime Data Area
)
3. 执行引擎(
Execution Engine
)
4. 本地库接口(
Native Interface
)
各组件的作用:
首先通过类加载器(ClassLoader
)会把
Java
代码转换成字节码
运行时数据区(Runtime Data Area
)再把字节码加载到内存中
而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(
Execution Engine
),将字节码翻译成底层系统指令,再交由
CPU 去执行
而这个过程中需要调⽤其他语⾔的本地库接口(Native Interface
)来实现整个程序的功能。
二、运行时数据区/运行时内存区域
Java 虚拟机在执行
Java
程序的过程中会把它管理的内存划分成若干个不同的数据区域。JDK1.8
和之前的版本略有不同。
JDK1.8之前:
JDK1.8:
线程私有的:
程序计数器
虚拟机栈
本地方法栈
线程共享的:
堆
方法区
直接内存 (非
运行时数据区的⼀部分
)
(1)程序计数器
程序计数器(Program Counter Register):是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
字节码解释器工作时就是通过改变这个计数器的值来选取下⼀条需要执⾏的字节码指令。程序的分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的命令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间的计数器互不影响,独立存储,我们称这块内存区域为
“
线程私有
”
的内存。
从上面的介绍中我们知道程序计数器主要有两个作用:
1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪了。
此区域是唯一一个虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
(2)java虚拟机栈
Java 虚拟机栈(
Java Virtual Machine Stacks
):描述的是
Java方法执行的内存模型:每个方法在执行的同时都会创建⼀个帧栈(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每⼀个方法从调用直至执行完成的过程,就对应着⼀个栈帧在虚拟机栈中⼊栈到出栈的过程。它的线程也是私有的,生命周期与线程相同。
局部变量表存放了编译期可知的各种基本数据类型(boolean
、
byte
、
char
、
short
、
int
、
float
、
long、double
)、对象引用和
returnAddress
类型(指向了⼀条字节码指令的地址)。
Java 虚拟机栈的局部变量表的空间单位是槽(
Slot
),其中
64
位长度的
double
和
long
类型会占用两个 Slot。局部变量表所需内存空间在编译期完成分配,当进⼊⼀个方法时,该方法需要在帧中分配多⼤的局部变量是完全确定的,在方法运行期间不会改变局部变量表的大小。
Java虚拟机栈有两种异常状况:如果线程请求的栈的深度大于虚拟机所允许的深度,将抛出 StackOverflowError异常;如果扩展时无法申请到足够的内存,就会抛出
OutOfMemoryError
异常。
其他:
与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java方法执行的内存模型,每次方法调用的数据都是通过栈传递的。
Java 内存可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java 虚拟机栈是由⼀个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)
局部变量表主要存放了编译期可知的各种数据类型(boolean、byte
、
char
、
short
、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是⼀个指向对象起始地址的引用指针,也可能是指向⼀个代表对象的句柄或其他与此对象相关的位置)。
Java 虚拟机栈会出现两种错误: StackOverFlowError 和 OutOfMemoryError 。
StackOverFlowError : 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最⼤深度的时候,就抛出 StackOverFlowError 错误。
OutOfMemoryError : 若 Java 虚拟机堆中没有空闲内存,并且垃圾回收器也⽆法提供更多内存的话。就会抛出 OutOfMemoryError 错误。
Java 虚拟机栈也是线程私有的,每个线程都有各⾃的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。
扩展:那么方法/函数如何调用?
Java
栈可用类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每⼀次函数调用都会有⼀个对应的栈帧被压⼊ Java 栈,每⼀个函数调用结束后,都会有⼀个栈帧被弹出。
Java 方法有两种返回方式:
1. return 语句。
2. 抛出异常。
不管哪种返回方式都会导致栈帧被弹出。
(3) 本地方法栈
本地方法栈(Native Method Stack):与虚拟机栈所发挥的作用是非常相似的,它们之间的区别只不过是虚拟机栈为虚拟机执行
Java方
法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的
Native 方
法服务。
Java 虚拟机规范没有对本地方法栈中方法使用的语言、使用的方式和数据结构做出强制规定,因此具体的虚拟机可以自由地实现它。比如:Sun HotSpot
虚拟机直接把
Java
虚拟机栈和本地方法栈合⼆为一。
与Java
虚拟机栈一样,本地方法栈也会抛出
StackOverflowError
和
OutOfMemoryError
异常。
(4)Java堆
Java堆(
Java Heap):是被所有线程所共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是:存放对象实例,几乎所有的对象实例都在这⾥分配内存。
Java世界中
“几
乎
”
所有的对象都在堆中分配,但是,随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致⼀些微妙的变化,所有的对象都分配到堆上也渐渐 变得不那么
“
绝对
”
了。从
jdk 1.7开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做
“GC”
堆(
Garbage Collected Heap)。从内存回收的角度看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代。
再细致⼀点有:Eden 空间、
From Survivor
、
To Survivor
空间等。进⼀步划分的目的是更好地回收内存,或者更快地分配内存。
从内存分配角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。不过⽆论如何划分,都与存放的内容无关,⽆论哪个区域,存储的都仍然是对象实例,进⼀步划分的目的是为了更好地回收内存,或者更快地分配内存。
Java 虚拟机规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。在实现时,可以是固定大小的,也可以是可扩展的。如果在堆中没有完成实例分配。并且堆也无法扩展时,将会抛出 OutOfMemoryError
异常。
JDK7及JDK7之前
JDK 8
版本之后方法区(
HotSpot
的永久代)被彻底移除了(
JDK1.7
就已经开始了),取而代之是元空间,元空间使用的是直接内存。
上图所示的
Eden
区、两个
Survivor
区都属于新生代(为了区分,这两个
Survivor
区域按照顺
序被命名为
from
和
to
),中间⼀层属于老年代。
大部分情况,对象都会首先在 Eden 区域分配,在一次新⽣代垃圾回收后,如果对象还存活,则会进⼊
s0
或者
s1
,并且对象的年龄还会加
1(Eden
区
->Survivor
区后对象的初始年龄变为
1),当它的年龄增加到⼀定程度(默认为
15 岁),就会被晋升到老年代中。对象晋升到⽼年代的年龄阈值,可以通过参数
-XX:MaxTenuringThreshold
来设置。
Hotspot遍历所有对象时,按照年龄从小到大对其所占用的空间进行累积,当累积的某个年龄大小超过了
survivor区的一半时,取这个年龄和MaxTenuringThreshold
中更小的⼀个值,作为新的晋升年龄阈值。
堆这⾥最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:
1. OutOfMemoryError: GC Overhead Limit Exceeded
: 当
JVM花太多时间执⾏垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
2. java.lang.OutOfMemoryError: Java heap space :
假如在创建新的对象时
, 堆内存中的空间不足以存放新创建的对象
,
就会引发 java.lang.OutOfMemoryError: Java heap space 错误。
(和本机物理内存无关,和你配置的内存大小有关!
)
(5)方法区
方法区(Method Area
):与
Java 堆⼀样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
虽然 Java
虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做
Non-Heap(非堆),其目的应该就是与
Java
堆区分开来。
Java 虚拟机规范对方法区的限制非常宽松,除了和
Java 堆⼀样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
根据Java
虚拟机规范规定,当方法区无法满足内存分配需求时,将抛出
OutOfMemoryError
异常。
《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的
JVM
上方法区的实现肯定是不同的了。
方
法区和永久代的关系很像
Java
中接口和类的关系,类实现了接口,而永久代就是
HotSpot
虚拟机对虚拟机规范中方
法区的⼀种实现方式。
也就是说,永久代是
HotSpot
的概念,方法区是 Java 虚拟机规范中的定义,是⼀种规范,而永久代是⼀种实现,⼀个是标准⼀个是实现,其他的虚拟机实现并没有永久代这⼀说法。
JDK 1.8 的时候,方法区(
HotSpot
的永久代)被彻底移除了(
JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。
与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。
为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
1.整个永久代有⼀个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
当你元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace
2. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可⽤空间来控制,这样能加载的类就更多了。
3. 在 JDK8
,合并
HotSpot
和
JRockit
的代码时
, JRockit
从来没有⼀个叫永久代的东⻄
, 合并之后就没有必要额外的设置这么⼀个永久代的地方了。
(6)运行时常量池
运行时常量池:运行时常量池(Runtime Constant Pool):是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有⼀些信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进⼊方法区的运行时常量池中存放。
Java 虚拟机对 Class 文件每一部分(⾃然也包括常量池)的格式都有严格的规定,每⼀个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行。
1. JDK1.7之前运行时常量池逻辑包含字符串常量池存放在方法区,
此时
hotspot虚拟机对方法区的实现为永久代
2. JDK1.7 字符串常量池被从方法区拿到了堆中
,
这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行
时常量池剩下的东西还在方法区
,
也就是hotspot中的永久代 。
3. JDK1.8 hotspot移除了永久代用元空间
(Metaspace)
取而代之, 这时候字符串常量池还在堆
运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间 (Metaspace)
(7)直接内存
直接内存:直接内存(Direct Memory):并不是虚拟机运⾏时数据区的⼀部分,也不是 Java 虚拟机规范中定义的内存区域。但是这部分内存也频繁地使用,而且也可能导致OutOfMemoryError 异常。
本地直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存大小以及处理器寻址空间的限制。如果各个内存区域总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError 异常。
JDK1.4 中新加入的 NIO(New Input/Output)
类
,引入了⼀种基于
通道(
Channel
)
与缓存区(Buffer)
的
I/O
⽅式,它可以直接使⽤ Native 函数库直接分配堆外内存,然后通过⼀个存储在Java
堆中的 DirectByteBuffer 对象作为这块内存的引⽤进⾏操作。这样就能在⼀些场景中显著提⾼性能,因为避免了在
Java
堆和
Native
堆之间来回复制数据。
三、堆、栈
(1)堆和栈的区别
堆和栈(虚拟机栈)是完全不同的两块内存区域,一个是线程共享的,⼀个是线程独享的。⼆者之间最⼤的区别就是存储的内容不同:
堆中主要存放对象实例。
栈(局部变量表)中主要存放各种基本数据类型、对象的引用。
从作用来说,栈是运行时的单位,而堆是存储的单位。栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪。在 Java 中一个线程就会相应有一个线程栈与之对应,因为不同的线程执行逻辑有所不同,因此需要一个独立的线程栈。而堆则是所有线程共享的。栈因为是运行单位,因此里面存储的信息都是跟当前线程(或程序)相关信息的。包括局部变量、程序运行状态、方法返回值等等;而堆只负责存储对象信息。
(2)堆中存什么?栈中存什么?
堆中存的是对象。栈中存的是基本数据类型和堆中对象的引用。一个对象的大小是不可估计的,或者说是可以动态变化的,但是在栈中,⼀个对象只对应了⼀个
4btye
的引用(堆栈分离的好处)。
为什么不把基本类型放堆中呢?
因为基本数据类型占用的空间一般是1~8个字节,需要空间比较少,而且因为是基本类型,所以不会出现动态增长的情况,长度固定,因此栈中存储就够了。如果把它存在堆中是没有什么意义的。基本类型和对象的引用都是存放在栈中,而且都是几个字节的一个数,因此在程序运行时,它们的处理方式是统一的。但是基本类型、对象引用和对象本身就有所区别了,因为⼀个是栈中的数据⼀个是堆中的数据。最常见的⼀个问题就是,
Java 中参数传递时的问题。
(3)为什么要把堆和栈区分出来呢?栈中不是也可以存储数据吗?
1. 从软件设计的角度看,栈代表了处理逻辑,而堆代表了数据。这样分开,使得处理逻辑更为清晰。分而治之的思想。这种隔离、模块化的思想在软件设计的方方面面都有体现。
2. 堆与栈的分离,使得堆中的内容可以被多个栈共享(也可以理解为多个线程访问同⼀个对象)。这种共享的收益是很多的。一方面这种共享提供了⼀种有效的数据交互方式
(
如:共享内存
),另一方面,堆中的共享常量和缓存可以被所有栈访问,节省了空间。
3. 栈因为运行时的需要,比如:保存系统运行的上下文,需要进行地址段的划分。由于栈只能向上增⻓,因此就会限制住栈存储内容的能力。而堆不同,堆中的对象是可以根据需要动态增长的,因此栈和堆的拆分,使得动态增长成为可能,相应栈中只需记录堆中的一个地址即可。
四、JAVA对象大小的计算
基本数据的类型的大小是固定的。对于非基本类型的 Java
对象,其大小就值得商榷。在
Java
中,⼀个空 Object 对象的大小是
8 byte
,这个大小只是保存堆中⼀个没有任何属性的对象的大小。看下面语句:
Object ob = new Object();
这样在程序中完成了⼀个 Java
对象的⽣命,但是它所占的空间为:
4 byte + 8 byte
。
4 byte 是上面部分所说的Java
栈中保存引用的所需要的空间。而那
8 byte
则是
Java
堆中对象的信息。因为所有的
Java 非基本类型的对象都需要默认继承
Object
对象,因此不论什么样的
Java
对象,其大小都必须是大于
8 byte
。有了
Object 对象的大小,我们就可以计算其他对象的大小了。
Class MaNong {
int count;
boolean flag;
Object obj; }
MaNong 的大小为:空对象大小(8 byte) + int 大小(4 byte) + boolean 大小(1 byte) + 空Object 引用的大小(4byte) = 17byte。但是因为 Java 在对对象内存分配时都是以 8 的整数倍来分,因此大于 17 byte 的最接近 8 的整数倍的是 24,因此此对象的大小为 24 byte。
五、对象的访问定位的两种方式
建立对象就是为了使用对象,我们的Java
程序通过栈上的
reference
数据来操作堆上的具体对 象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有①使用句柄
和
②直接指针
两种:
1. 句柄:
如果使用句柄的话,那么
Java
堆中将会划分出⼀块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息
2. 直接指针:
如果使用直接指针访问,那么
Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,⽽
reference
中存储的直接就是对象的地址。
这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference
中存储的是稳定的句柄
地址,在对象被移动时只会改变句柄中的实例数据指针,而
reference
本身不需要修改。使用直
接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
六、判断垃圾可以回收的方法/如何判断对象是否死亡
垃圾收集器在对堆区和方法区进行回收前,首先要确定这些区域的对象哪些可以被回收,哪些暂时还不能回收,这就要用到判断对象是否存活的算法。
1、引用计数法
基本思想
引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,就将该对象实例分配给一个变量,该变量计数设置为
1。当任何其它变量被赋值为这个对象的引用时,计数加1
(
a = b
,则
b
引用的对象实例的计数器加
1),但当⼀个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减
1
。任何引用计数器为
0 的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减
1
。
优缺点
优点:引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。
缺点:无法检测出循环引用。如父对象有⼀个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为
0
。
例如如下代码:
public class Demo{
public static void main(String[] args){
MyObject object1 = new MyObject();
MyObject object2 = new MyObject();
object1.object = object2;
object2.object = object1;
object1 = null;
object2 = null;
}
}
class MyObject{
MyObject object;
}
这段代码是用来验证引用计数算法不能检测出循环引用。最后面两句将 object1
和
object2
赋值为
null,也就是说object1
和
object2 指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0
,那么垃圾收集器就永远不会回收它们。
2
、可达性分析算法
可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点 GC ROOT 开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象
在
Java
语⾔中,可作为
GC Roots
的对象包括下面几种:
1.
虚拟机栈中引用的对象(栈帧中的本地变量表);
2. 方
法区中类静态属性引用的对象;
3. 方
法区中常量引用的对象;
4.
本地方法栈中
JNI
(
Native方
法)引用的对象。
七、垃圾回收从哪里开始
查找哪些对象是正在被当前系统使用的。上面分析的堆和栈的区别,其中栈是真正进行程序执行地方,所以要获取哪些对象正在被使用,则需要从
Java 栈开始。同时,⼀个栈是与⼀个线程对应的,因此,如果有多个线程的话,则必须对这些线程对应的所有的栈进行检查。
同时,除了栈外,还有系统运行时的寄存器等,也是存储程序运行数据的。这样,以栈或寄存器中的引用为起点,我们可以找到堆中的对象,又从这些对象找到对堆中其他对象的引用,这种引用逐步扩展,最终以
null 引用或者基本类型结束,这样就形成了⼀颗以 Java 栈中引用所对应的对象为根节点的⼀颗对象树。如果栈中有多个引用,则最终会形成多颗对象树。在这些对象树上的对象,都是当前系统运行所需要的对象,不能被垃圾回收。而其他剩余对象,则可以视为无法被引用到的对象,可以被当做垃圾进行回收。
八、被标记为垃圾的对象一定会被回收吗
即使在可达性分析算法中不可达的对象,也并非是“非
死不可
”
,这时候它们暂时处于
“
缓刑
”阶段,要真正宣告⼀个对象死亡,至少要经历两次标记过程。
第一次标记:如果对象在进行可达性分析后发现没有与 GC Roots
相连接的引用链,那它将会被第一次标记;
第⼆次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行finalize() 方法。在finalize() 方法中没有重新与引用链建立关联关系的,将被进行第⼆次标记。第⼆次标记成功的对象将真的会被回收,如果对象在
finalize() 方
法中重新与引用链建⽴了关联关系,那么将会逃离本次回收,继续存活。
九、JAVA中四种引用类型
无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与
“
引用
”有关。 JDK1.2
之前,
Java
中引用的定义很传统:如果
reference类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。
JDK1.2以后,
Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种 (引用强度逐渐减弱)
1. 强引用
(StrongReference)
以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于
必不可少的生活用品
,垃圾回收器绝不会回收它。当内存空间不足,
Java虚拟机宁愿抛出
OutOfMemoryError错误,使程序异常终⽌,也不会靠随意回收具有强引用的对象来解 决内存不足问题。
2. 软引用
(SoftReference)
如果⼀个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
软引用可以和⼀个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,
JAVA
虚拟机就会把这个软引用加⼊到与之关联的引用队列中。
3. 弱引用
(WeakReference)
如果⼀个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,⼀旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是⼀个优先级很低的线程, 因此不⼀定会很快发现那些只具有弱引用的对象。
弱引用可以和⼀个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,
Java
虚拟机就会把这个弱引用加入到与之关联的引用队列中。
4.虚引用(
PhantomReference
)
"虚引⽤"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的⽣命周期。如果⼀个对象仅持有虚引用,那么它就和没有任何引用⼀样,在任何时候都可能被垃圾回收。
虚引用主要用来跟踪对象被垃圾回收的活动。
虚引用与软引用和弱引用的⼀个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收⼀个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,
把这个虚引用加入到与之关联的引用队列中。
程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
特别注意,在程序设计中⼀般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为
软引用
可以加速
JVM
对垃圾内存的回收速度,可以维护系统的运行安全,防行内存溢出
(
OutOfMemory
)等问题的产生。
十、内存泄漏、内存溢出
- 内存溢出(Out Of Memory) :就是申请内存时,JVM没有足够的内存空间。通俗说法就是去蹲坑发现坑位满了。
- 内存泄露 (Memory Leak):就是申请了内存,但是没有释放,导致内存空间浪费。通俗说法就是有人占着茅坑不拉屎。
(1)内存泄漏
长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄漏,尽管短生命周期对象已经不再需要,但是因为长生命周期持有它的引用而导致不能被回收,这就是
Java
中内存泄漏的发⽣场景。
在上图中:对象 X 引用对象 Y,X 的生命周期比 Y 的生命周期长,Y生命周期结束的时候,垃圾回收器不会回收对象Y。
内存泄漏例子:
a.静态集合类引起内存泄漏
静态集合的生命周期和 JVM 一致,所以静态集合引用的对象不能被释放。
public class OOM {
static List list = new ArrayList();
public void oomTests(){
Object obj = new Object();
list.add(obj);
}
}
b.单例模式:
和上面的例子原理类似,单例对象在初始化后会以静态变量的方式在 JVM 的整个生命周期中存在。如果单例对象持有外部的引用,那么这个外部对象将不能被 GC 回收,导致内存泄漏。
c.数据连接、IO、Socket等连接
创建的连接不再使用时,需要调用 close 方法关闭连接,只有连接被关闭后,GC 才会回收对应的对象(Connection,Statement,ResultSet,Session)。忘记关闭这些资源会导致持续占有内存,无法被 GC 回收。
try {
Connection conn = null;
Class.forName("com.mysql.jdbc.Driver");
conn = DriverManager.getConnection("url", "", "");
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("....");
} catch (Exception e) {
}finally {
//不关闭连接
}
}
d.变量不合理的作用域
一个变量的定义作用域大于其使用范围,很可能存在内存泄漏;或不再使用对象没有及时将对象设置为 null,很可能导致内存泄漏的发生。
public class Simple {
Object object;
public void method1(){
object = new Object();
//...其他代码
//由于作用域原因,method1执行完成之后,object 对象所分配的内存不会马上释放
object = null;
}
}
e.引用了外部类的非静态内部类
非静态内部类(或匿名类)的初始化总是需要依赖外部类的实例。默认情况下,每个非静态内部类都包含对其包含类的隐式引用,若在程序中使用这个内部类对象,那么即使在包含类对象超出范围之后,也不会被回收(内部类对象隐式地持有外部类对象的引用,使其成不能被回收)。
f.Hash 值发生改变
对象Hash值改变,使用HashMap、HashSet等容器中时候,由于对象修改之后的Hah值和存储进容器时的Hash值不同,会导致无法从容器中单独删除当前对象,造成内存泄露。
g.ThreadLocal 造成的内存泄漏
ThreadLocal 可以实现变量的线程隔离,但若使用不当,就可能会引入内存泄漏问题。
(2)内存溢出
在JVM的几个内存区域中,除了程序计数器外,其他几个运行时区域都有发生内存溢出(OOM)异常的可能。
1.Java堆溢出
Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。
2.虚拟机栈和本地方法栈溢出
HotSpot虚拟机中将虚拟机栈和本地方法栈合二为一,因此对于HotSpot来说,-Xoss参数(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由-Xss参数来设定。关于虚拟机栈和本地方法栈,有两种异常:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出
StackOverflowError
异常。 - 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出
OutOfMemoryError
异常。
3.方法区和运行时常量池溢出
这里再提一下方法区和运行时常量池的变迁,JDK1.7以后字符串常量池移动到了堆中,JDK1.8在直接内存中划出一块区域元空间来实现方区域。
String:intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的 字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。在JDK 6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,永久代本身内存不限制可能会出现错误:
java.lang.OutOfMemoryError: PermGen space
4.本机直接内存溢出
直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致。
(3)尽量避免内存泄漏的方法
1. 尽量不要使用
static
成员变量,减少生命周期;
2. 及时关闭资源;
3. 不用的对象,可以手动设置为
null
。
十一、常用的垃圾收集算法
1. 标记
-
清除算法(
Mark-Sweep
)
标记-清除算法采用从根集合(
GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收。标记
-清除算法不需要进行对象的移动,只需对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记
-
清除算法直接回收不存活的对象,因此会造成内存碎片。
该算法分为“标记”和
“
清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:
1. 效率问题
2. 空间问题(标记清除后会产生大量不连续的碎片)
2. 复制算法
(Copying)
复制算法的提出是为了克服句柄的开销和解决内存碎片的问题。它开始时把堆分成 ⼀个对象面和多个空闲面, 程序从对象面为对象分配空间,当对象满了,基于
copying
算法的垃圾收集就从根集合(GC Roots)中扫描活动对象,并将每个活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲洞),这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。
3.
标记
-
整理算法
(Mark-compact)
标记-整理算法采用标记
-清除算法⼀样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记
-
整理算法是在标记
-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。
4. 分代收集算法
分代收集算法是目前大部分 JVM 的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。⼀般情况下将堆区划分为老年代(
Tenured Generation
)和新生代(
Young Generation), 在堆区之外还有⼀个代就是永久代(
Permanet Generation
)。
老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。
比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象
的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的
间对它进行分配担保,所以我们必须选择
“
标记
-
清除
”
或
“
标记
-
整理
”
算法进行垃圾收集。
十二、分代收集算法
(1)为什么要用分代收集算法
分代的垃圾回收策略,是基于这样⼀个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。
在 Java
程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如
Http
请求中的
Session 对象、线程、
Socket 连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有⼀些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:
String 对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
在不进行对象存活时间区分的情况下,每次垃圾回收都是对整个堆空间进行回收,花费时间相对会长,同时,因为每次回收都需要遍历所有存活对象,但实际上,对于生命周期长的对象而言,这种遍历是没有效果的,因为可能进行了很多次遍历,但是他们依旧存在。因此,分代垃圾回收采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收。
(2)年轻代和老年代垃圾回收算法
1、年轻代(
Young Generation
)的回收算法
(
主要以
Copying
为主
)
1. 所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
2. 新生代内存按照 8:1:1
的比例分为⼀个
eden
区和两个
survivor
(
survivor0、
survivor1)区。大部分对象在 Eden
区中生成。回收时先将
Eden
区存活对象复制到⼀个
survivor0
区,然后清空
eden 区,当这个 survivor0
区也存放满了时,则将
eden
区和
survivor0
区存活对象复制到另⼀个
survivor1 区,然后清空 eden
区 和这个
survivor0
区,此时
survivor0
区是空的,然后将
survivor0
区和
survivor1 区交换,即保持 survivor1
区为空, 如此往复。
3. 当 survivor1
区不足以存放
Eden
区 和
survivor0区 的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发⼀次
Full GC
(
Major GC
),也就是新生代、老年代都进行回收。
4. 新生代发生的 GC
也叫做
Minor GC
,
MinorGC
发生频率比较⾼(不⼀定等
Eden
区满了才触发)。
2、年老代(
Old Generation
)的回收算法(主要以
Mark-Compact 为主(标记
-
整理算法))
1. 在年轻代中经历了 N 次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是⼀些生命周期较长的对象。
2. 内存比新生代也大很多(大概比例是1 : 2
),当老年代内存满时触发
Major GC
即
Full GC
,
Full GC 发生频率比较低,老年代对象存活时间比较长,存活率标记高。
十三、浮动垃圾
由于在应用运行的同时进行垃圾回收,所以有些垃圾可能在垃圾回收进行完成时产生,这样就造成了“Floating Garbage”
,这些垃圾需要在下次垃圾回收周期时才能回收掉。所以,并发收集器⼀般需要
20%的预留空间用于这些浮动垃圾。
十四、内存碎片
由于不同 Java 对象存活时间是不⼀定的,因此,在程序运行一段时间以后,如果不进行内存整理,就会出现零散的内存碎片。碎片最直接的问题就是会导致无法分配大块的内存空间,以及程序运行效率降低。所以,在上面提到的基本垃圾回收算法中,
“
复制
”方式
和
“
标记
-
整理
”方
式,都可以解决碎片的问题。
十五、常用的垃圾收集器
1. Serial 收集器(复制算法)
新生代单线程收集器,标记和清理都是单线程,优点是简单高效。是 client 级别默认的
GC 方
式,可以通过 - XX:+UseSerialGC
来强制指定。
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。⼤家看名字就知道这个收集器是⼀个单线程收集器了。它的
“
单线程
”
的意义不仅仅意味着它只会使用⼀条垃圾收集线程去完成垃圾收集⼯作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop
The World"
),直到它收集结束。
新生代采用复制算法,老年代采用标记-
整理算法。
虚拟机的设计者们当然知道
Stop The World 带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。
但是 Serial
收集器有没有优于其他垃圾收集器的地⽅呢?当然有,
简单而高效(与其他收集器
的单线程相比)
。
Serial
收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效 率。Serial
收集器对于运行在
Client
模式下的虚拟机来说是个不错的选择。
2. Serial Old 收集器(标记
-
整理算法)
老年代单线程收集器,Serial 收集器的老年代版本。
Serial 收集器的老年代版本,它同样是⼀个单线程收集器。它主要有两大用途:⼀种用途是在JDK1.5
以及以前的版本中与
Parallel Scavenge
收集器搭配使用,另⼀种用途是作为
CMS收集器的后备方案。
3. ParNew 收集器(停止
-
复制算法)
新生代收集器,可以认为是 Serial 收集器的多线程版本,在多核
CPU
环境下有着比
Serial
更好的表现。
新生代采用复制算法,老年代采用标记-整理算法。
它是许多运行在 Server 模式下的虚拟机的首要选择,除了
Serial
收集器外,只有它能与
CMS 收集器(真正意义上的并发收集器)配合工作。
4. Parallel Scavenge 收集器(停止
-
复制算法)
并行收集器,追求高吞吐量,⾼效利用 CPU
。吞吐量⼀般为
99%
吞吐量= 用
户线程时间
/ (用
户线程时间
+GC线程时间
)
。
适合后台应用等对交互相应要求不高的场景。是 server 级别默认采用的
GC方
式,可用
-
XX:+UseParallelGC
来强制指定,用
-XX:ParallelGCThreads=4
来指定线程数。
新生代采用复制算法,老年代采用标记-整理算法。
这是 JDK1.8 默认收集器
5. Parallel Old 收集器(停止
-
复制算法)
Parallel Scavenge 收集器的老年代版本,并行收集器,吞吐量优先。
Parallel Scavenge 收集器的老年代版本。使用多线程和
“
标记
-
整理
”算法。在注重吞吐量以及CPU
资源的场合,都可以优先考虑
Parallel Scaveng
6. CMS(Concurrent Mark Sweep)收集器(标记
-
清除算法)
高并发、低停顿,追求最短 GC 回收停顿时间,
cpu
占用比较高,响应时间快,停顿时间短,多核
cpu 追求高响应时间的选择。
CMS 是英文 Concurrent Mark-Sweep 的简称,是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。在启动
JVM
的参数加上“- XX:+UseConcMarkSweepGC”
来指定使用
CMS
垃圾回收器。
CMS 使用的是标记-
清除的算法实现的,所以在
GC 的时候会产生大量的内存碎片,当剩余内存不能满足程序运行要求时,系统将会出现
Concurrent Mode Failure
,临时
CMS
会采用
Serial Old 回收器进行垃圾清除,此时的性能将会被降低。
7. G1
G1 收集器在后台维护了⼀个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字
Garbage-First
的由来。)
十六、CMS
CMS(
Concurrent Mark Sweep
)收集器是⼀种以获取最短回收停顿时间为目标的收集器。它非
常符合在注重用户体验的应用上使用。
CMS(
Concurrent Mark Sweep
)收集器是
HotSpot
虚拟机第⼀款真正意义上的并发收集器,
它第⼀次实现了让垃圾收集线程与用户线程(基本上)同时工作。
从名字中的Mark Sweep
这两个词可以看出,
CMS
收集器是⼀种
“
标记
-
清除
”
算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂⼀些。整个过程分为四个步骤:
初始标记: 暂停所有的其他线程,并记录下直接与 root
相连的对象,速度很快 ;
并发标记: 同时开启 GC 和用户线程,用⼀个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为⽤用线程可能会不断的更新引用域,所以
GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地⽅。
重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那⼀部分对象的标记记录,这个阶段的停顿时间⼀般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
并发清除: 开启用户线程,同时 GC
线程开始对未标记的区域做清扫。
从它的名字就可以看出它是⼀款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:
对 CPU 资源敏感;
⽆法处理浮动垃圾;
它使用的回收算法-“标记
-
清除
”
算法会导致收集结束时会有大量空间碎片产生。
CMS 是英文 Concurrent Mark-Sweep 的简称,是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。是使用标记清除算法实现的,整个过程分为四步:
1. 初始标记:记录下直接与
root
相连的对象,暂停所有的其他线程,速度很快;
2. 并发标记:同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以
GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
3. 重新标记:重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。【这个阶段的停顿时间⼀般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短】;
4. 并发清除:开启用户线程,同时 GC
线程开始对为标记的区域做清扫。
CMS 的优缺点:
主要优点:并发收集、低停顿;
主要缺点:对 CPU 资源敏感、无法处理浮动垃圾、它使用的回收算法
“
标记
-
清除
”算法会导致收集结束时会有大量空间碎片产生。
十七、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 划分内存空间以及有优先级的区域回收⽅式,保证了
G1 收集器在有限时间内可以尽可能⾼的收集效率(把内存化整为零)。
垃圾回收的瓶颈:传统分代垃圾回收方式,已经在⼀定程度上把垃圾回收给应用带来的负担降到了最小,把应用的吞吐量推到了⼀个极限。但是他无法解决的一个问题,就是 Full GC 所带来的应用暂停。在一些对实时性要求很高的应用场景下,GC 暂停所带来的请求堆积和请求失败是无法接受的。这类应用可能要求请求的返回时间在几百甚至几十毫秒以内,如果分代垃圾回收方式要达到这个指标,只能把最⼤堆的设置限制在⼀个相对较小范围内,但是这样有限制了应用本身的处理能力,同样也是不可接受的。
分代垃圾回收方式确实也考虑了实时性要求而提供了并发回收器,支持最大暂停时间的设置,但是受限于分代垃圾回收的内存划分模型,其效果也不是很理想。
G1 可谓博采众家之长,力求到达一种完美。它吸取了增量收集优点,把整个堆划分为一个一个等大小的区域(
region
)。内存的回收和划分都以
region
为单位;同时,它也吸取了
CMS 的特点,把这个垃圾回收过程分为几个阶段,分散⼀个垃圾回收过程;而且,
G1 也认同分代垃圾回收的思想,认为不同对象的生命周期不同,可以采取不同收集方式,因此,它也支持分代的垃圾回收。为了达到对回收时间的可预计性,
G1
在扫描了
region 以后, 对其中的活跃对象的大小进行排序,首先会收集那些活跃对象小的
region,以便快速回收空间(要复制的活跃对象少了),因为活跃对象小,里面可以认为多数都是垃圾,所以这种方式被称为
Garbage First
(
G1)的垃圾回收算法,即:垃圾优先的回收。
十八、垃圾回收策略、垃圾回收时机
1. Minor / Scavenge GC
所有对象创建在新生代的 Eden 区,当
Eden
区满后触发新生代的
Minor GC
,将
Eden
区和非空闲
Survivor 区存 活的对象复制到另外⼀个空闲的
Survivor
区中。保证⼀个
Survivor
区是空的,新生代
Minor GC 就是在两个 Survivor
区之间相互复制存活对象,直到
Survivor
区满为止。
Minor/Scavenge 这种方式的 GC
是在年轻代的
Eden
区进行,不会影响到年老代。因为大部分对象都是从 Eden 区开始的,同时
Eden
区不会分配的很大,所以
Eden
区的
GC 会频繁进行。因而,⼀般在这里需要使用速度快、 效率高的算法,使
Eden
去能尽快空闲出来。
2. Full GC
对整个堆进⾏整理,包括 Young、
Tenured
和
Perm
。
Full GC
因为需要对整个堆进行回收,所以比
Minor GC 要慢,因此应该尽可能减少
Full GC
的次数。在对
JVM
调优的过程中,很大⼀部分工作就是对于
Full GC
的调节。
Minor 有如下原因可能导致
Full GC
:
1、 调用 System.gc()
,会建议虚拟机执行
Full GC
。只是建议虚拟机执行
Full GC,但是虚拟机不⼀定真正去执行。
2、 老年代空间不足,原因:老年代空间不足的常见场景为大对象直接进入老年代、长期存活的对象进入老年代等。为了避免以上原因引起的
Full GC
,应当尽量不要创建过大的对象以及数组。除此之外,可以通过
-Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 - XX:MaxTenuringThreshold
调大对象进入老年代的年龄,让对象在新生代多存活一段时间;
3、 空间分配担保失败:使用复制算法的 Minor GC
需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC
;
4、JDK 1.7
及以前的永久代空间不足。在
JDK1.7
及以前,
HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为⼀些
Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用
CMS GC
的情况下也会执行
Full GC
。如果经过
Full GC 仍然回收不了,那么虚拟机会抛出
java.lang.OutOfMemoryError
。为避免以上原因引起的
Full GC,可采用的方法为增大永久代空间或 转为使用
CMS GC
。
5、Concurrent Mode Failure
执行
CMS GC 的过程中,同时有对象要放入老年代,而此时老年代空间不足(可能 是
GC
过程中浮动垃圾过多导致暂时性的空间不足),便会报
Concurrent Mode Failure
错误,并触发
Full GC
。
十九、内存分配
1. 对象优先在
Eden
区分配:大多数情况下,对象在新生代
Eden
区分配,当
Eden
区空间不够时,发起 Minor GC
。
2. 大对象直接进入老年代:大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给⼤对象。- XX:PretenureSizeThreshold
,大于此值的对象直接在老年代分配,避免在
Eden
区和
Survivor 区之间的大量内存复制。
3. 长期存活的对象将进入老年代:为对象定义年龄计数器,对象在
Eden
出生并经过
Minor GC 依然存活,将移动到
Survivor
中,年龄就增加
1
岁,增加到⼀定年龄则移动到老年代中。-XX:MaxTenuringThreshold 用来定义年龄的阈值。
4. 动态对象年龄判定:为了更好的适应不同程序的内存情况,虚拟机不是永远要求对象年龄必须达到了某个值才能进入老年代,如果
Survivor
空间中相同年龄所有对象大小的总和大于
Survivor 空间的⼀半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需达到要求的年龄。
5. 空间分配担保
(1)在发生
Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么
Minor GC
可以确认是安全的;
(2)如果不成立的话,虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行⼀次 Minor GC
;如果小于,或者
HandlePromotionFailure
设置不允许冒险,那么就要进行⼀次
Full GC
。
是因为新生代采用复制收集算法,假如大量对象在Minor GC后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的,这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代。老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间。
二十、类文件结构、类加载机制、类加载器、类与类加载器的关系、双亲委派模式、3种类加载器
(1)类文件结构
Class 文件没有任何分隔符,严格按照上面结构表中的顺序排列。无论是顺序还是数量,甚至于数据存储的字节序这样的细节,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。
1. 魔数(
magic
):每个
Class
⽂件的头
4
个字节称为魔数(
Magic Number),它的唯⼀作用是确定这个文件是否为⼀个能被虚拟机接受的
Class
⽂件,即判断这个文件是否符合
Class 文
件规范。
2. 文件的版本:
minor_version
和
major_version
。
3. 常量池:
constant_pool_count
和
constant_pool
:常量池中主要存放两⼤类常量:字面量(
Literal)和符号引用(
Symbolic References
)。
4. 访问标志:access_flags
:用于识别⼀些类或者接⼝层次的访问信息。包括:这个
Class
是类还是接⼝、是否
定义了
Public
类型、是否定义为
abstract
类型、如果是类,是否被声明为了
final
等等。
5.类索引、父类索引与接口索引集合:
this_class
、
super_class
和
interfaces
。
6. 字段表集合:
field_info
、
fields_count
:字段表(
field_info)用于描述接口或者类中声明的变量; fields_count
字段数目:表示
Class文
件的类和实例变量总数。
7. ⽅法表集合:methods
、
methods_count
8. 属性表集合:
attributes
、
attributes_count
(2)类加载
虚拟机把描述类的数据从 Class ⽂件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的
Java
类型,这就是虚拟机的类加载机制。
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使⽤、卸载
7
个阶段。其中验证、准备、解析
3
个部分统称为连接,这
7
个阶段发⽣的顺序如下图所示:
(3)类加载各阶段的作用
1. 加载
在加载阶段,虚拟机需要完成以下三件事情:
1、通过⼀个类的全限定名来获取定义此类的⼆进制字节流;
2、 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
3、在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问接口。
2. 验证
主要是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致上分为
4
个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
1、文件格式校验:验证字节流是否符合 class 文件的规范,并且能被当前版本的虚拟机处理。只有通过这个阶段的验证后,字节流才会进⼊内存的⽅法区进行存储,所以后面的
3个阶段的全部是基于方法区的存储结构进行的,不会再直接操作字节流;
2、 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java 语言规范的要求。目的是保证不存在不符合
Java
语⾔规范的元数据信息;
3、 字节码验证:该阶段主要工作是进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为;
4、 符号引用验证:最后⼀个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三个阶段
——
解析阶段中发生。符号引用验证的目的是确保解析动作能正常执行。
3. 准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象⼀起分配在 Java 堆中。实例化不是类加载的⼀个过程,类加载发生在所有实例化操作之前,并且类加载只进⾏⼀次,实例化可以进⾏多次。
初始值是默认值 0 或
false
或
null
。如果类变量是常量(
final),那么会按照表达式来进行初始化,而不是赋值为 0
。
public static final int value = 123;
4. 解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
5. 初始化
在准备阶段,变量已经赋过⼀次系统要求的初始值了,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外⼀个角度来表达:初始化阶段是执行类构造器
() 方
法的过程。
(4)有哪些类加载器?分别有什么作用?
1. 启动类加载器
(Bootstrap ClassLoader)
:这个类加载器是由
C++ 语⾔实现的,是虚拟机自身的⼀部分。负责将存在
<JAVA_HOME>\lib 目
录中的,或者被
-Xbootclasspath 参数所指定的路径中的类库加载到虚拟机内存中。启动类加载器无法被
Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用
null
即可;
2. 其他类加载器:由
Java
语⾔实现,独立于虚拟机外部,并且全都继承自抽象类
java.lang.ClassLoader。如扩展类加载器和应用程序类加载器:
(1
)扩展类加载器
(Extension ClassLoader)
:这个类加载器由
sun.misc.Launcher$ExtClassLoader 实现,它负责加载
<JAVA_HOME>\lib\ext目
录中的,或者被
java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
(2
)应用程序类加载器
(Application ClassLoader)
:这个类加载器由
sun.misc.Launcher$AppClassLoder 实现。由于个类加载器是
ClassLoader
中的
getSystemClassLoader() 方法的返回值,所以⼀般也称之为系统类加载器。它负责加载用户路径(
ClassPath
)所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,⼀般情况下这个就是程序中默认的类加载器。
三种类加载器各自加载的类库:
启动(Bootstrap)类加载器
:启动类加载器是用本地代码实现的类加载器,它负责将JAVA_HOME/lib下面的核心类库或-Xbootclasspath选项指定的jar包等虚拟机识别的类库加载到内存中。由于启动类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用。具体可由启动类加载器加载到的路径可通过System.getProperty("sun.boot.class.path")
查看。
扩展(Extension)类加载器
:扩展类加载器是由Sun的ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的,它负责将JAVA_HOME /lib/ext或者由系统变量-Djava.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器,具体可由扩展类加载器加载到的路径可通过System.getProperty("java.ext.dirs")
查看。
系统(System)类加载器
:系统类加载器是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的,它负责将用户类路径(java -classpath或-Djava.class.path变量所指的目录,即当前类所在路径及其引用的第三方类库的路径,下的类库加载到内存中。开发者可以直接使用系统类加载器,具体可由系统类加载器加载到的路径可通过System.getProperty("java.class.path")
查看。
(5)类与类加载器的关系?
类加载器虽然只用于实现类的加载动作,但它在 Java
程序中起到的作用却远远不限于类加载阶段。
对于任意⼀个类,都需要由加载它的类加载器和这个类本身⼀同确立其在 Java 虚拟机中的唯⼀性,每个类加载器,都拥有⼀个独立的类名称空间。换句话说:比较两个类是否
“
相等
”,只有在这两个类是由同⼀个类加载器加载的前提下才有意义,否则,即使这两个类来源于同⼀个
Class ⽂件,被同⼀个虚拟机加载,只要加载它们的类加载器不同,那么这两个类就必定不相等。
(6)谈谈你对双亲委派模型的理解?工作过程?为什么要使用?
应用程序⼀般是由上述的三种类加载器相互配合进行加载的,如果有必要,还可以加⼊自己定义的类加载器,它们的关系如下图所示:
双亲委派模型的工作过程:
如果⼀个类加载器收到了类加载请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。每⼀个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
使用双亲委派模型的好处:
Java 类随着它的类加载器⼀起具备了⼀种带有优先级的层次关系。例如:类 java.lang.Object,它存放在 rt.jar中,无论哪⼀个类加载器需要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同⼀个类(使用的是同⼀个类加载器加载的)。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了⼀个
java.lang.Object
类,并放在程序的 ClassPath中,那么系统将会出现多个不同的
Object
类,
Java
类型体系中最基础的行为也就无法保证,应用程序也将变得一片混乱。
双亲委派模型的主要代码实现:
实现双亲委派的代码都集中在 java.lang.ClassLoader 的
loadClass() 方法中,逻辑清晰易懂:先检查是否已经被加载过,若没有加载则调用父加载器的
loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父类加载器。如果父类加载失败,抛出
ClassNotFoundException
异常后,再调用自己的
findClass() 方
法进行加载。
虚拟机出于安全等因素考虑,不会加载<JAVA_HOME>/lib目录下存在的陌生类。换句话说,虚拟机只加载<JAVA_HOME>/lib目录下它可以识别的类。因此,开发者通过将要加载的非JDK自身的类放置到此目录下期待启动类加载器加载是不可能的。
(7)怎么实现⼀个自定义的类加载器?需要注意什么?
若要实现自定义类加载器,只需要继承 java.lang.ClassLoader
类,并且重写其
findClass() 方
法即可。
(8)怎么打破双亲委派模型?
1. 自己写⼀个类加载器;
2. 重写
loadClass()
⽅法
3. 重写
findClass()
⽅法
这里最主要的是重写 loadClass 方法,因为双亲委派机制的实现都是通过这个方法实现的,先找父加载器进行加载,如果父加载器无法加载再由自己来进行加载,源码里会直接找到根加载器,重写了这个⽅法以后就能自己定义加载的⽅式了。
(9)有哪些实际场景是需要打破双亲委派模型的?
JNDI 服务,它的代码由启动类加载器去加载,但
JNDI
的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现部署在应用程序的 classpath
下的
JNDI
接口提供者
(SPI, Service Provider Interface) 的代码,但启动类加载器不可能
“
认识
”
之些代码,该怎么办?
为了解决这个困境,Java
设计团队只好引入了⼀个不太优雅的设计:
线程上下文类加载器(Thread Context ClassLoader)
。这个类加载器可以通过
java.lang.Thread
类的 setContextClassLoader() 方法进行设置,如果创建线程时还未设置,它将会从父线程中继承⼀个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。有了线程上下文类加载器,
JNDI
服务使用这个线程上下文类加载器去加载所需要的 SPI 代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构 来逆向使⽤类加载器,已经违背了双亲委派模型,但这也是无可奈何的事情。Java
中所有涉及
SPI 的加载动作基本上都采用这种方式,例如
JNDI
、
JDBC
、
JCE
、
JAXB
和
JBI
等。
Java SPI的具体约定为:当服务的提供者提供了服务接口的一种实现之后,在jar包的META-INF/services/目录里同时创建一个以服务接口命名的文件,该文件里就是实现该服务接口的具体实现类。而当外部程序装配这个模块的时候,就能通过该jar包META-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。基于这样一个约定就能很好的找到服务接口的实现类,而不需要再代码里制定。
SPI的接口是Java核心库的一部分,是由启动类加载器来加载的;而SPI的实现类是由系统类加载器来加载的。启动类加载器是无法找到 SPI 的实现类的(因为它只加载 Java 的核心库),按照双亲委派模型,启动类加载器无法委派系统类加载器去加载类。也就是说,类加载器的双亲委派模式无法解决这个问题。
线程上下文类加载器正好解决了这个问题。线程上下文类加载器破坏了“双亲委派模型”,可以在执行线程中抛弃双亲委派加载链模式,使程序可以逆向使用类加载器。
SPI 的接口是 Java 核心库的一部分,是由引导类加载器来加载的;
SPI 实现的 Java 类一般是由系统类加载器来加载的。
引导类加载器是无法找到 SPI 的实现类的,因为它只加载 Java 的核心库。
它也不能代理给系统类加载器,因为它是系统类加载器的祖先类加载器。
(10)为何 HotSpot 虚拟机要使用解释器与编译器并存的架构?
解释器:程序可以迅速启动和执行,消耗内存小 (类似人工,成本低,到后期效率低);
编译器:随着代码频繁执行会将代码编译成本地机器码 (类似机器,成本高,到后期效率高)。在整个虚拟机执行架构中,解释器与编译器经常配合⼯作。
两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大(如部分嵌入式系统),可以使用解释执行节约内存,反之可以使用编译执行来提升效率,解释执行可以节约内存,而编译执行可以提升效率。因此,在整个虚拟机执行架构中,解释器与编译器经常配合⼯作。
二十一、JAVA内存模型、内存间交互
(1)JAVA内存模型
处理器和内存不是同数量级,所以需要在中间建立中间层,也就是高速缓存,这会引出缓存⼀致性问题。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同⼀主内存(
Main Memory),有可能操作同⼀位置引起各自缓存不⼀致,这时候需要约定协议在保证⼀致性。
Java 内存模型
(Java Memory Model
,
JMM)
:屏蔽掉了各种硬件和操作系统的内存访问差异,以实现让
Java 程序在各种平台下都能达到⼀致性的内存访问效果
主内存与工作内存
Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
Java 内存模型规定了所有的变量都存储在主内存(
Main Memory
)中,每个线程有自己的工作线程(Working Memory),保存主内存副本拷贝和自己私有变量,不同线程不能访问⼯作内存中的变量。线程间变量值的传递需要通过主内存来完成
(2)内存交互
关于主内存与工作内存之间的具体的交互协议,即:⼀个变量如何从主内存拷贝到工作内存、如何从⼯作内存同步主内存之类的实现细节,
Java
内存模型中定义⼀下八种操作来完成:
1. lock(锁定
)
:作用于主内存的变量。它把⼀个变量标志为⼀个线程独占的状态;
2. unlock(解锁
):作用于主内存的变量,它把处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
3. read(读取
)
:作用于主内存的变量,它把⼀个变量的值从主内存传输到线程的⼯作内存中,以便随后的
load动作使⽤;
4. load(载入
)
:作用于工作内存的变量,它把
read
操作从主内存中得到变量值放入工作内存的变量的副本中;
5. use(使用
):作用于工作内存的变量, 它把工作内存中⼀个变量的值传递给执行引擎,每当虚拟机遇到⼀个需要使用到变量的值的字节码指令时将会执行这个操作;
6. assign(赋值
):作用于工作内存的变量。它把⼀个从执行引擎接收到的值赋值给⼯作内存的变量,每当虚拟机遇到需要给⼀个变量赋值的字节码时执⾏这个操作;
7. store(存储
)
:作用于工作内存的变量。它把一个工作内存中一个变量的值传递到主内存中,以便随后的write操作使用;
8. write(写⼊
)
:作用于主内存的变量。它把
store
操作从工作内存中得到的变量的值放入主内存的变量中。
如果要把⼀个变量从工作内存复制到工作内存,那就要按顺序执⾏ read
和
load 操作,如果要把变量从工作内存同步回主内存,就要按顺序执⾏
store
和
write
操作。
上诉
8
种基本操作必须满⾜的规则:
1. 不允许
read
和
load
、
store
和
write
操作之一单独出现;
2. 不允许⼀个线程丢弃它的最近的
assign
操作,即变量在工作内存中改变之后必须把该变化同步回主内存;
3. 不允许一个线程无原因地(没有发⽣过任何
assign
操作)把数据从线程的工作内存同步回主内存中;
4. ⼀个新的变量只能在主内存中
“
诞⽣
”
,不允许在工作内存中直接使用⼀个未被初始化(
load
或
assign)的变量,换句话说就是对⼀个变量实施
use
和
store
操作之前,必须执行过了
assign
和
load
操作;
5. ⼀个变量在同⼀时刻只允许⼀条线程对其进⾏
lock
操作,但
lock 操作可以被同⼀线程重复执行多次,多次执行
lock
后,只有执⾏相同次数的
unlock
,变量才会被解锁;
6. 如果对⼀个变量执⾏
lock 操作,将会清空⼯作内存中此变量的值,在执⾏引擎使⽤这个变量前,需要重新执行
load
或
assign
操作初始化变量的值;
7. 如果⼀个变量事先没有被
lock
操作锁定,则不允许对它执⾏
unlock
操作,也不允许去
unlock ⼀个被其他线 程锁定主的变量;
8. 对⼀个变量执行
unlock
操作之前,必须先把此变量同步回主内存中(执⾏
store
和
write
操作)。
二十二、JAVA对象的创建过程
Step1:类加载检查
虚拟机遇到⼀条 new 指令时,⾸先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
Step2:分配内存
在类加载检查通过后,接下来虚拟机将为新生对象
分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把⼀块确定大小的内存从
Java
堆中划分出来。
分配
⽅式
有
“
指针碰撞
”
和
“
空闲列表
”
两种,
选择哪种分配方式由
Java
堆是否规整决定,⽽
Java
堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定
。
内存分配的两种方式:
选择以上两种方式中的哪⼀种,取决于 Java 堆内存是否规整。而
Java 堆内存是否规整,取决于GC
收集器的算法是
"
标记
-
清除
"
,还是
"
标记
-
整理
"
(也称作
"
标记
-
压缩
"),值得注意的是,复制 算法内存也是规整的
内存分配并发问题
在创建对象的时候有⼀个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
CAS+失败重试: CAS 是乐观锁的⼀种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。
虚拟机采用
CAS
配上失败重试的方式保证更新操作的原子性。
TLAB: 为每⼀个线程预先在
Eden
区分配⼀块内存,
JVM
在给线程中的对象分配内存时,首先在 TLAB
分配,当对象大于
TLAB
中的剩余内存或
TLAB 的内存已用尽时,再采用上述的
CAS
进行内存分配
Step3:初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这⼀步操作保证了对象的实例字段在
Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的 数据类型所对应的零值。
Step4:设置对象头
初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的
GC
分代年龄等信息。
这些信息存放在对象头
中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
Step5:执行
init 方
法
在上面工作都完成之后,从虚拟机的视角来看,⼀个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,
<init> ⽅法还没有执行,所有的字段都还为零。所以⼀般来说, 执行
new
指令之后会接着执行
<init> ⽅法,把对象按照程序员的意愿进行初始化,这样⼀个真正可用的对象才算完全产⽣出来
二十三、JVM内存分配与回收、堆内存中对象分配的基本策略
(1)JVM内存分配与回收
Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,
Java 自动内存管理最核心的功能是
堆
内存中对象的分配与回收。
Java 堆是垃圾收集器管理的主要区域,因此也被称作
GC
堆(
Garbage Collected Heap
)
.从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以
Java 堆还可以细分为:新生代和老年代:再细致⼀点有:
Eden
空间、
From Survivor
、
To Survivor
空间等。
进⼀步划分的目的
是更好地回收内存,或者更快地分配内存。
Eden 区、
From Survivor0("From")
区、
To Survivor1("To")
区都属于新⽣代,Old Memory
区属于老年代。
大部分情况,对象都会首先在 Eden 区域分配,在⼀次新⽣代垃圾回收后,如果对象还存活,则会进入
s0
或者
s1
,并且对象的年龄还会加
1(Eden
区
->Survivor
区后对象的初始年龄变为
1)
,当它的年龄增加到⼀定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数
-XX:MaxTenuringThreshold
来设置。
Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的⼀半时,取这个年龄和 MaxTenuringThreshold
中更小的⼀个值,作为新的晋升年龄阈值。
经过这次 GC
后,
Eden
区和
"From"
区已经被清空。这个时候,
"From"
和
"To"
会交换他们的角色,也就是新的"To"
就是上次
GC
前的
“From”
,新的
"From"
就是上次
GC
前的
"To"。不管怎样,都会保证名为
To
的
Survivor
区域是空的。
Minor GC
会⼀直重复这样的过程,直到
“To”区被填 满,
"To"
区被填满之后,会将所有对象移动到⽼年代中。
(2)堆内存中对象的分配的基本策略
1. 对象优先在
eden
区分配
目前主流的垃圾收集器都会采用分代回收算法,因此需要将堆内存分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
大多数情况下,对象在新⽣代中 eden 区分配。当
eden 区没有足够空间进行分配时,虚拟机将发起⼀次
Minor GC
2.大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。
为什么要这样呢?
为了避免为⼤对象分配内存时由于分配担保机制带来的复制而降低效率。
3.长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这⼀点,虚拟机给每个对象⼀个对象年龄(
Age)计数器。
如果对象在 Eden 出⽣并经过第⼀次
Minor GC
后仍然能够存活,并且能被
Survivor 容纳的话,将被移动到
Survivor
空间中,并将对象年龄设为
1.
对象在
Survivor
中每熬过⼀次
MinorGC,年龄就增加
1
岁,当它的年龄增加到⼀定程度(默认为
15 岁),就会被晋升到⽼年代中。对象晋升到⽼年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
动态对象年龄判定:
Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了
survivor 区的⼀半时,取这个年龄和 MaxTenuringThreshold
中更小的⼀个值,作为新的晋升年龄阈值
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两⼤种:
部分收集 (Partial GC)
:
新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是
Major GC
在
有的语境中也用于指代整堆收集;
混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
整堆收集 (Full GC)
:收集整个
Java
堆和方法区。
二十四、如何判断一个常量是废弃常量、如何判断一个类是无用类
(1)如何判断一个常量是废弃常量
运行时常量池主要回收的是废弃的常量。那么,我们如何判断⼀个常量是废弃常量呢?
假如在常量池中存在字符串 "abc",如果当前没有任何
String对象引用该字符串常量的话,就说明常量
"abc"
就是废弃常量,如果这时发生内存回收的话而且有必要的话,
"abc" 就会被系统清理出常量池。
(2)如何判断一个类是无用类
方法区主要回收的是无用的类,那么如何判断⼀个类是无用的类的呢?
判定⼀个常量是否是“废弃常量
”比较
简单,而要判定⼀个类是否是
“无用
的类
”的条件则相对苛刻许多。类需要同时满足下面
3
个条件才能算是
“无用
的类
”
:
该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
加载该类的 ClassLoader
已经被回收。
该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类 的方法。
虚拟机可以对满⾜上述 3
个条件的无用类进行回收,这⾥说的仅仅是
“
可以
”,而并不是和对象⼀ 样不使用了就会必然被回收。
二十五、哪些对象可以作为GC roots
1. 虚拟机栈(栈桢中的本地变量表)中的引用的对象
2. 方法区中的类静态属性引用的对象
3. 方法区中的常量引用的对象
4. 本地方法栈中JNI(Native方法)的引用的对象
二十六、Class.forName() 和 ClassLoader()区别
(1)类加载:
装载:通过累的全限定名获取二进制字节流,将二进制字节流转换成方法区中的运行时数据结构,在内存中生成Java.lang.class对象;
链接:执行下面的校验、准备和解析步骤,其中解析步骤是可以选择的;
校验:检查导入类或接口的二进制数据的正确性;(文件格式验证,元数据验证,字节码验证,符号引用验证)
准备:给类的静态变量分配并初始化存储空间;
解析:将常量池中的符号引用转成直接引用;
初始化:激活类的静态变量的初始化Java代码和静态Java代码块,并初始化程序员设置的变量值。
使用:
卸载:
(2)类加载时机:
创建类的实例,也就是new一个对象
访问某个类或接口的静态变量,或者对该静态变量赋值
调用类的静态方法
反射(Class.forName("com.lyj.load"))
初始化一个类的子类(会首先初始化子类的父类)
JVM启动时标明的启动类,即文件名和类名相同的那个类
除此之外,下面几种情形需要特别指出:
对于一个final类型的静态变量,如果该变量的值在编译时就可以确定下来,那么这个变量相当于“宏变量”。Java编译器会在编译时直接把这个变量出现的地方替换成它的值,因此即使程序使用该静态变量,也不会导致该类的初始化。反之,如果final类型的静态Field的值不能在编译时确定下来,则必须等到运行时才可以确定该变量的值,如果通过该类来访问它的静态变量,则会导致该类被初始化。
(3)区别
Class.forname():将类的.class文件加载到jvm,并且对类进行解释,执行类中的static代码块,初始化类中的static变量。
loadClass():只会将类的.class文件加载到jvm,而不执行static代码块,也不初始化static变量。
Class.forName(className)方法,内部实际调用的方法是
forName0(className, true, ClassLoader.getClassLoader(caller), caller);
@CallerSensitive
public static Class<?> forName(String className) throws ClassNotFoundException {
Class<?> caller = Reflection.getCallerClass();
return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}
(1)className:表示我们要加载的类名
(2)true:指Class被加载后是不是必须被初始化。 不初始化就是不执行static的代码即静态代码,在这里默认为true,也就是默认实现类的初始化。
(3)ClassLoader.getClassLoader(caller):表示类加载器,到这你会发现forNanme其实也是使用的ClassLoader类加载器加载的。
(4)caller:指定类加载器。
所以,在这里你可以指定是否在class加载后被初始化。而且底层还是使用的classloader加载的。
一旦初始化,就会触发目标对象的 static块代码执行,static参数也也会被再次初始化。
可以调用 Class.forName(String name, boolean initialize,ClassLoader loader) 方法来手动选择在加载类的时候是否要对类进行初始化。
ClassLoader.loadClass(className)方法,内部实际调用的方法是
ClassLoader.loadClass(className,false);
第2个 boolean参数,表示目标对象是否进行链接,false表示不进行链接,不进行链接意味着不进行包括初始化等一些列步骤,那么静态块和静态对象就不会得到执行。
二十七、如何利用监控工具调优?
暂未用过java监控工具
1
、堆信息查看
1. 可查看堆空间大小分配(年轻代、年老代、持久代分配)
2. 提供即时的垃圾回收功能
3. 垃圾监控(长时间监控回收情况)
4. 查看堆内类、对象信息查看:数量、类型等
5. 对象引用情况查看
有了堆信息查看方面的功能,我们⼀般可以顺利解决以下问题:
1. 年老代年轻代大小划分是否合理
2. 内存泄漏垃
3. 圾回收算法设置是否合理
2
、线程监控
线程信息监控:系统线程数量
线程状态监控:各个线程都处在什么样的状态下
Dump 线程详细信息:查看线程内部运⾏情况
死锁检查
3
、 热点分析
1. CPU 热点:检查系统哪些方法占⽤的⼤量
CPU
时间;
2. 内存热点:检查哪些对象在系统中数量最大(⼀定时间内存活对象和销毁对象⼀起统计)这两个东西对于系统优化很有帮助。我们可以根据找到的热点,有针对性的进⾏系统的瓶颈查找和进行系统优化,而不是漫无目的的进行所有代码的优化。
4
、快照
快照是系统运行到某⼀时刻的⼀个定格。在我们进⾏调优的时候,不可能用眼睛去跟踪所有系统变化,依赖快照功能,我们就可以进行系统两个不同运⾏时刻,对象(或类、线程等)的不同,以便快速找到问题。
举例说,我要检查系统进行垃圾回收以后,是否还有该收回的对象被遗漏下来的了。那么,我可以在进行垃圾回收前后,分别进行一次堆情况的快照,然后对比两次快照的对象情况。
5
、内存泄露检查
内存泄漏是比较常见的问题,而且解决⽅法也比较通用,这里可以重点说⼀下,而线程、热点方面的问题则是具体问题具体分析了。
内存泄漏⼀般可以理解为系统资源(各⽅⾯的资源,堆、栈、线程等)在错误使用的情况下,导致使用完毕的资源无法回收(或没有回收),从而导致新的资源分配请求无法完成,引起系统错误。内存泄漏对系统危害比较大,因为它可以直接导致系统的崩溃。
二十八、JVM一些参数
1.
堆设置
-Xms:初始堆大小
-Xmx:最大堆大小
-XX:NewSize=n:设置年轻代大小
-XX:NewRatio=n:设置年轻代和年老代的比值。如
:
为
3
,表示年轻代与年老代比值为
1
:
3,年轻代占整个年轻代 年老代和的
1/4
-XX:SurvivorRatio=n:年轻代中
Eden
区与两个
Survivor
区的⽐值。注意
Survivor
区有两个。如:
3
,表示 Eden:
Survivor=3
:
2
,⼀个
Survivor
区占整个年轻代的
1/5
-XX:MaxPermSize=n:设置持久代大小
2.
收集器设置
-XX:+UseSerialGC:设置串行收集器
-XX:+UseParallelGC:设置并行收集器
-XX:+UseParalledlOldGC:设置并行年老代收集器
-XX:+UseConcMarkSweepGC
:设置并发收集器
3.
垃圾回收统计信息
-XX:+PrintGC:开启打印
gc
信息
-XX:+PrintGCDetails:打印 gc
详细信息
-XX:+PrintGCTimeStamps
-Xloggc:filename
4.
并行收集器设置
-XX:ParallelGCThreads=n:设置并行收集器收集时使⽤的
CPU
数
-XX:MaxGCPauseMillis=n:设置并行收集最⼤暂停时间
-XX:GCTimeRatio=n:设置垃圾回收时间占程序运⾏时间的百分比
5.
并发收集器设置
-XX:+CMSIncrementalMode:设置为增量模式。适用于单
CPU
情况
-XX:ParallelGCThreads=n:设置并发收集器年轻代收集方式为并行收集时,使用的
CPU
数。并行收集线程数。
二十九、Java对象头
(1)堆中的Java对象
在Hotspot中一个Java对象包含如下三个部分:
- 对象头
- 实例信息
- 对齐信息
(2)JVM中的对象头:
jvm对象头信息是与对象自身定义的数据无关的额外存储的信息,由于它存在于对象中,jvm规范中安装对象类型,分两种类型:
- 普通对象包含:Mark Word、元数据指针(Klass Pointer)
- 数组对象包含:Mark Word、元数据指针(Klass Pointer)、Array Length
(3)不同类型JVM下,对象头每一部分占用内存大小(单位:bit):
数据类型 | 32位jmv | 64位jvm | 开启指针压缩的64位JVM |
Mark Word | 32 | 64 | 64 |
Klass Pointer | 32 | 64 | 32 |
Array Length | 32 | 32 | 32 |
可见,在64位的JVM中,开启指针压缩(-XX:UseCompressedOops)只会影响到Klass Pointer。而数组长度不管在什么类型的JVM里都是32bit。
(4)不同类型JVM下,对象头占用内存大小(单位:bit)
对象类型 | 32位jmv | 64位jvm | 开启指针压缩的64位JVM |
普通对象 | 64 | 128 | 96 |
数组对象 | 96 | 160 | 128 |
由此可见,对象头占用的空间还是不小的,那对象头里究竟存储了什么?还是要分32JVM和64位JVM来说明,往下看
普通对象对象头:
|--------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|
数组对象对象头:
|---------------------------------------------------------------------------------|
| Object Header (96 bits) |
|--------------------------------|-----------------------|------------------------|
| Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
|--------------------------------|-----------------------|------------------------|
(5)32位JVM-->Mark Word
mark word里存放的是对象运行时的信息,不同状态的对象里mark word 存放的信息是不同的,如下:
|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 | Normal |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 | Biased |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | lock:2 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | lock:2 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |
|-------------------------------------------------------|--------------------|
其中各部分的含义如下:
lock:2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个mark word表示的含义不同。
biased_lock | lock | 状态 |
---|
0 | 01 | 无锁 |
1 | 01 | 偏向锁 |
0 | 00 | 轻量级锁 |
0 | 10 | 重量级锁 |
0 | 11 | GC标记 |
biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
age:4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold
选项最大值为15的原因。
identity_hashcode:25位的对象标识Hash码,采用延迟加载技术。调用方法System.identityHashCode()
计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程Monitor中。
thread:持有偏向锁的线程ID。
epoch:偏向时间戳。
ptr_to_lock_record:指向栈中锁记录的指针。
ptr_to_heavyweight_monitor:指向管程Monitor的指针。
(6)64位JVM-->Mark Word
|------------------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|------------------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | Normal |
|------------------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | Biased |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | lock:2 | Lightweight Locked |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | lock:2 | Heavyweight Locked |
|------------------------------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |
|------------------------------------------------------------------------------|--------------------|
说明:
1、名词:
- age: GC分代年龄
- identify_hashcode: 对象的hashcode值
- thread: 偏向线程的Id
- biased_lock: 是否是偏向锁,因为只占一个bit,所以只有0和1
- epoch: 偏向时间戳
- ptr_to_lock_record: 指向栈中轻量级锁记录的指针
- ptr_to_heavyweight_monitor:指向栈中重量级锁的指针
- GC标记: 用于GC算法对对象的标记
- gc_info: GC算法给不同状态的标记信息
2、实现机制
对象头信息是跟对象自身定义的数据结构无关的。这些信息所记录的状态是用于JVM对对象的管理的。更重要的是,不同状态的存储内容基本上是互斥的。所以基于节省空间的角度考虑,Mark Word被设计成动态的。
3、identify_hashcode 既然有方法可以生成为什么要放在对象头里?
- 当一个对象的hashCode()未被重写时,调用这个方法会返回一个由随机数算法生成的值。因为一个对象的hashCode不可变,所以需要存到对象头中。当再次调用该方法时,会直接返回对象头中的hashcode。
- identify_hashcode 采用延迟加载的方式生成。只有调用hashcode()时,才会写入对象头。若一个类的hashCode()方法被重写,对象头中将不存储hashcode信息,因为一般我们自己实现的hashcode()并未将生成的值写入对象头。
4、当对象的状态不是默认状态时,对象的hashcode去哪儿了
- 当是轻量级锁/重量级锁时,jvm会将对象的 mark word 复制一份到栈帧的Lock Record中。等线程释放该对象时,再重新复制给对象。
- 如果一个对象头中存在hashcode,则无法使用偏向锁。
(7)Klass Pointer
类型指针存放的是该对象对应的类的指针。即该指针应该指向方法区的内存区域。
这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。
如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。为了节约内存可以使用选项+UseCompressedOops
开启指针压缩,其中,oop即ordinary object pointer普通对象指针。开启该选项后,下列指针将压缩至32位:
- 每个Class的属性指针(即静态变量)
- 每个对象的属性指针(即对象变量)
- 普通对象数组的每个元素指针
当然,也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。
(8)Array Length
数组长度只在数组类型的对象中存在。用于记录数组的长度。避免获取数组长度时,动态计算。以空间换时间的做法。