JVM内存结构
下图来自: cyc
分别介绍一下:
- 程序计数器
线程私有,用于记录当前线程正在执行的字节码指令的地址(如果正在执行的是本地方法则为空)。
JVM栈
也叫方法栈、Java虚拟机栈,是线程私有的,线程在执行每个方法时都会创建一个栈帧
,
其中包含局部变量表
、返回信息
、操作数栈
和常量池引用
。
- 本地方法栈
用于保存native
方法的相关信息。
- 堆
是JVM
所管理的最大的一块内存,被所有线程所共享,主要用于存放对象实例。
堆不需要连续内存
,并且可以动态增加内存,若增加失败则会抛出OutOfMemeoryError
异常。
可以通过-Xms
和-Xmx
这个两个虚拟机参数来指定一个程序的堆内存大小。
第一个参数设置初始值,第二个参数设置最大值。
- 方法区
它也是各个线程所共享的内存区域,用于存储已被虚拟机加载的类信息
、静态变量
和常量
等。
为了更容易管理方法区,从JDK1.8
开始,并将方法区移至元空间[metaspace]
,
它位于本地内存
中,而不是虚拟机内存中。
方法区是一个JVM
规范,永久代
和元空间
都是它的一种实现方式。
在JDK1.8
之后,原来永久代的数据被分到堆和元空间中,
元空间存储类的元信息,而堆存储静态变量和常量池。
分代
由于对象存活时间
的不同,可以将堆划分为两个区域:
新生代[Young Generation]
、老年代[Old Generation]
。
来看下示意图:
新生代
新生代用来存放新生的对象,一般占据堆的1/3
空间,
由于频繁创建对象,所以新生代会频繁触发MinorGC
进行垃圾回收。
新生代又分为:Eden
、Survivor From
和Survivor To
三个区。
Eden
区
Java
新对象的出生地(如果新创建的对象占用的内存很大,则直接分配到老年代)。
当Eden
区内存不足的时候就会触发MinorGC
,对新生代区进行一次垃圾回收。
ServivorFrom
上一次GC
的幸存者,作为这一次GC
的被扫描者。
ServivorTo
保留了一次MinorGC
过程中的幸存者。
那么MinorGC
的过程是怎么样的?
主要分为三步: 复制 -> 清空 -> 互换
第一步,复制。
首先,将Eden
和SurvivorFrom
区域内存活的对象复制到SurvivorTo
区域,
并将这些对象的年龄+1
,如果SurvivorTo
区间不够则放入老年代。
如果有对象的年龄已经达到了老年的标准,默认15
,也进入老年代。
第二步,清空。
然后,清空Eden
和SurvivorFrom
区域中的对象。
第三步,互换。
最后,互换SurvivorFrom
与SurvivorTo
区域,原SurvivorTo
称为下一次MinorGC
时的SurvivorFrom
区。
老年代
老年代主要存放应用程序中生命周期较长的内存对象。该区域的对象比较稳定,所以MajorGC
不会频繁执行,
在进行MajorGC
前一般会先进行一次MinorGC
,
如果有新生代的对象需要晋升到老年代,但此时老年代内存不足,才会触发MajorGC
。
另一种触发场景是:需要创建一个较大的对象,而老年代找不到足够的连续内存空间。
MajorGC
采用标记清除算法
,首先扫描一次老年代区域,标记存活的对象,然后回收没有标记的对象。
MajorGC
的耗时比较长,因为要扫描再回收,并会产生内存碎片。
类加载机制
类的生命周期
主要分为七步:
类的加载过程
主要分为五步,如上图所示。
重点需要掌握: 准备
和初始化
。
第一步,加载
:
将.class
文件通过二进制流的形式传输并加载到内存。
第二步,验证
:
校验传入的二进制流是否符合JVM
的规范,如:
是否以魔数[caffe babe]
开头?版本号是否正确?
第三步,准备
:
JVM
会为类变量
分配内存并初始化零值
。
有一个例外,静态成员变量同时被final
关键字修饰,如:
public static final int a = 3
那么,此时a
的值就不是0
,而是3
,
因为它被final
关键字修饰,一旦初始化,则永远不可能发生改变。
第四步,解析
:
主要任务就是将常量池中的符号引用
替换为直接引用
。
第五步,初始化
:
根据执行顺序,分别执行静态代码块
,或类成员变量的赋值语句
。
那么什么时候会触发初始化呢?
主要分为以下四种情况:
第一种,遇到new
、getstatic
、putstatic
、invokestatic
这四条字节码指令时。
分别对应: 使用new
关键字创建对象、读取或设置一个类的成员变量、调用类方法。
第二种,使用java.lang.reflect
包中的方法对类进行反射。
第三种,父类优先原则
,当初始化一个类时,如果它的父类还没进行初始化,则优先触发其父类的初始化。
第四种,当虚拟机启动时,它会初始化主类,也就是包含main()
的类。
来看个例子:
public class Initialization {
public Initialization() {
System.out.println("执行构造函数");
}
public static void main(String[] args) {
staticFunction();
}
{
System.out.println("执行普通代码块");
}
int b = 100;
static int a = 10;
static {
System.out.println("更改前,a = " + a);
System.out.println("执行静态代码块");
a = 20;
System.out.println("更改后,a = " + a);
}
private static void staticFunction() {
System.out.println("执行静态方法staticFunction()");
}
}
返回什么?
更改前,a = 10
执行静态代码块
更改后,a = 20
执行静态方法staticFunction()
首先,当Java
代码编译成字节码后,会有两个初始化方法。
分别为: 类初始化方法
和对象初始化方法
。
其中,类初始化方法
包含静态代码块
、类成员变量
的赋值语句,并按照它们出现的顺序排列。
那么上面的例子中,对于的类初始化方法
如下:
static int a = 10;
static {
System.out.println("更改前,a = " + a);
System.out.println("执行静态代码块");
a = 20;
System.out.println("更改后,a = " + a);
}
再来看下对象初始化方法
,它包含什么?
普通代码块
、普通成员变量的赋值语句
和构造函数
。
注意前两者按照其出现的顺序排列,而构造函数永远在最后。
对于上面的例子:
{
System.out.println("执行普通代码块");
}
int b = 100;
public Initialization() {
System.out.println("执行构造函数");
}
然后,我们再来进行分析:
首先,当我们执行main()
方法时,JVM
会加载Initialization
类。
在初始化阶段执行上面的类初始化方法
,所以返回:
更改前,a = 10
执行静态代码块
更改后,a = 20
注意,此时并没有创建对象,所以没有执行对象初始化方法
。
接着,调用staticFunction()
方法,则返回:
执行静态方法staticFunction()
升级一下,下面的代码会返回什么?
public class Initialization {
public Initialization() {
System.out.println("执行构造函数");
}
public static void main(String[] args) {
staticFunction();
}
{
System.out.println("执行普通代码块");
}
int b = 100;
static Initialization init = new Initialization();
static int a = 10;
static {
System.out.println("更改前,a = " + a);
System.out.println("执行静态代码块");
a = 20;
System.out.println("更改后,a = " + a);
}
private static void staticFunction() {
System.out.println("执行静态方法staticFunction()");
}
}
返回:
执行普通代码块
执行构造函数
更改前,a = 10
执行静态代码块
更改后,a = 20
执行静态方法staticFunction()
因为执行类成员变量init
的赋值语句时,需要创建Initialization
对象,
所以执行了对象初始化方法
,所以先返回:
执行普通代码块
执行构造函数
类加载器
主要分为以下三种:
- 启动类加载器: 负责加载
jre/lib/
目录下的Java
核心类。 - 扩展类加载器: 负责加载
jre/lib/ext
目录下的类。 - 应用程序类加载器: 负责加载
classpath
目录下的类。
双亲委派
机制的加载流程是怎样的?
当一个类加载器需要加载某个类时,会先把这个请求委托给自己的父类加载器去执行,
并一直向上委托,直至顶层的启动类加载器。
如果父类加载器能够完成类加载,则成功返回,否则子加载器自己尝试加载。
这样加载的好处是什么?
第一,避免类的重复加载。
第二,保证Java
核心类的安全稳定。
垃圾回收与算法
如何确定垃圾
引用计数法
在Java
中,引用和对象是有关联的。 如果要操作对象则必须用引用进行,
因此,一个简单的办法就是通过引用计数
来判断一个对象是否可以回收。
但是会存在循环引用
的问题。
- 可达性分析
为了解决引用计数的循环引用
问题,Java
使用了可达性
分析的方法。
通过一系列的"GC roots"
对象作为起点搜索。
如果在GC roots
和一个对象之间没有可达路径,则称该对象是不可达的。
需要注意,不可达对象
不等价于可回收对象
,不可达对象至少要经历两次
标记过程才会变成可回收对象。
标记清除算法(Mark - Swap)
最基础的垃圾回收算法,分为两个阶段: 标记
和清除
。
标记阶段标出所有需要回收的对象,清除阶段则回收被标记的对象所占用的空间,如图:
该算法最大的问题就是内存碎片化严重,后续可能会导致大对象无法找到可用空间的问题。
复制算法(Copying)
为了解决标记-清除
算法的内存碎片化问题而提出的算法。
按内存容量将内存划分为大小相等的两块,每次只使用其中一块,
当这一块内存满后,将还存活的对象复制到另一块上去,并把清掉已用内存,如图:
这种算法实现简单,不易产生碎片,但是可空内存被压缩到了原来的一半。
标记整理算法(Mark - Compact)
结合以上两种算法,标记阶段与标记-清除
算法相同,标记后不是清理对象,
而是将存活的对象移到内存的一端,然后清除其边界外的空间,如图:
分代收集算法
它的核心思想是根据对象存活时间
的不同,将堆内存划分为新生代
和老年代
。
那么新生代有什么特点?
每次MinorGC
都有大量垃圾需要被回收,所以可以使用复制(Copying)
算法,因为复制的操作比较少。
老年代的特点是什么?
每次MajorGC
只有少量垃圾需要被回收,所以可以采用标记-整理(Mark-Compact)
算法。
Java 四种引用类型
强引用
在Java
中最常见的就是强引用
,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。
当一个对象被强引用变量引用时,它处于可达状态,因此是不能被垃圾回收机制回收的,
举个例子:
public class StrongReferenceDemo {
private static final int N = 1024 * 1024;
public static void main(String[] args) {
Runtime rt = Runtime.getRuntime();
System.out.println("初始状态: " + rt.freeMemory() / N + "M(free)");
byte[] arr = new byte[4 * N];
System.out.println("创建数组: " + rt.freeMemory() / N + "M(free)");
System.gc();
System.out.println("垃圾回收: " + rt.freeMemory() / N + "M(free)");
arr = null;
System.gc();
System.out.println("断开引用: " + rt.freeMemory() / N + "M(free)");
}
}
// -----------------------------
// 初始状态: 14M(free)
// 创建数组: 10M(free)
// 垃圾回收: 10M(free)
// 断开引用: 14M(free)
软引用
软引用需要使用SoftReference
类来实现,对于软引用对象来说,只有系统内存不足时,它才会被回收。
弱引用
弱引用需要用WeakReference
类来实现,它比软应用的生存期更短,
一旦进行垃圾回收,不管JVM
的内存空间是否足够,总会回收该对象占用的内存。
虚引用
虚引用需要PhantomReference
类来实现,它不能单独使用,必须和引用队列联合使用。
虚引用的主要作用是跟踪对象被垃圾回收的状态。
GC垃圾收集器
Java
堆内存被划分为新生代
和老年代
两个部分,
新生代主要使用复制
垃圾回收算法,而老年代主要使用标记-整理
垃圾回收算法。
因此Java
虚拟机针对新生代和老年代提供了多种不同的垃圾收集器,
JDK1.6中Sun HotSpot
虚拟机的垃圾收集器如下:
Serial(单线程、复制算法)
Serial
,英文是连续的意思,是最基本的垃圾收集器,
它的特点是单线程
、使用复制
算法。
它只会使用一个CPU
或一条线程取完成垃圾收集工作,并在进行垃圾收集时,
必须暂停其他所有工作线程,直至垃圾收集结束。
ParNew(Serial+多线程)
ParNew
垃圾收集器其实是Serial
收集器的多线程版本。
它的特点是多线程
,且也使用复制
算法。
ParScavenge(多线程、复制算法、高吞吐量)
Parallel Scavenge
收集器也是一个新生代垃圾收集器,同样使用复制
算法,也是一个多线程
的垃圾器。
它的特点在于追求高吞吐量
,也就是最高效率地利用CPU
时间。
SerialOld(单线程、标记-整理算法)
Serial Old
是Serial
垃圾收集器的老年代版本,
它同样是个单线程
的收集器,使用标记-整理
算法。
ParOld(多线程、标记-整理算法)
Parallel Old
收集器是Parallel Scavenge
的老年代版本,
使用多线程
的标记-整理
算法,在JDK1.6
才开始提供。
在JDK1.6
之前,新生代使用Parallel Scavenge
收集器只能搭配老年代的Serial Old
收集器。
只能保证新生代的吞吐量优先,却无法保证整体的吞吐量,Parallel Old
收集器正是解决了这个问题。
CMS(多线程、标记-清除算法、低停顿)
CMS
的全称是Concurrent Mark Sweep
,它也是一种老年代垃圾收集器。
它的特点是保证在垃圾回收期间的停顿时间最短
,并且使用多线程
的标记-清除
算法。
整个过程主要分为四个阶段:
- 初始标记
标记一下GC Roots
能直接关联的对象,速度很快,不过仍然需要暂停所有工作线程。
- 并发标记
进行GC Roots
跟踪的过程,和工作线程一起工作,不需要暂停它们。
- 重新标记
重新标记在上一步发生标记变化的对象,仍然需要暂停所有工作线程。
- 并发清除
清除GC Roots
不可达对象,和工作线程一起工作,需要暂停它们。
G1(多线程、标记-整理算法、吞吐量与停顿的平衡)
它的特点是:
第一,基于多线程
和标记-整理
算法,不产生内存碎片。
第二,可以非常精确地控制停顿时间,在不牺牲吞吐量的前提下,实现低停顿
垃圾回收。
G1
收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,
并跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,
每次根据所允许的收集时间,优先回收垃圾最多的区域。
区域的划分和优先级策略确保G1
收集器可以在有限的时间内获取最高的垃圾收集效率。
参考
- 1、JVM基础系列第7讲:JVM 类加载机制{:target=”_blank”}
- 2、Java 虚拟机{:target=”_blank”}
- 3、jvm系列(一):java类的加载机制{:target=”_blank”}
- 4、介绍一下JVM里的垃圾回收{:target=”_blank”}
- 5、Java中的四种引用类型{:target=”_blank”}