什么是JVM?

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。(引用百度百科

Java技术体系包含的内容

SunJDK和OpenJDK内置的是 HotSpot JVM,是目前使用范围最广的Java虚拟机,当然还有很多虚拟机也非常优秀比如 KVM、Squawk VM、IBM J9 VM等。

深入理解JVM

不同的系统指令是不一样的如果只是把源码编译为专属的指令,那么只能在这个系统运行,如果想在另一个系统运行则需要重新编译的,Java为了达到一次编译到处运行的效果,选择的是源码编译成字节码而不是像C++直接编译为本地系统专属的指令,虚拟机就像一台计算机一样.class保存的就是虚拟机指令只要你的系统装上JVM就能不用再次编译源码就能直接运行。

JVM内存布局与分配

程序计数器

程序诈数器(Program Counter Register) 是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

虚拟机栈

与程序计数器一样,Java 虚拟机栈( Java:Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short.int、float、 long、 double)、 对象引用(reference类型)。

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分都可以扩展),如果扩展时申请内存失败抛出OutOfMemoryError异常。

本地方法栈

​ 本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)。

Java堆

新生代、老年代、永久代、元空间 仅仅是垃圾回收机制的概念其实它们还是存储在(新生代、老年代)或方法区(永久代、元空间)

Java堆也称GC堆,可以被所有线程共享的一块区域,几乎所有对象实例都放在这块内存。由于现在的垃圾收集器用分带收集法。所以还能细分为 新生代和老年代。新生代也可以继续细分为 Eden 空间、From Survivor空间和To Survivor空间。java堆可以处于物理不连续内存空间中,只要逻辑上连续即可。(-Xmx 和-Xms 控制大小)

新生代:用于存储新的容易死亡的对象。

老年代:用于存储长期存活的对象( 可以用JVM参数 设置对象大小界值和对象的年龄的界值来定义新老年代)

Java虚拟机每次使用新生代中的Eden和其中一块Survivor(From),在经过一次Minor GC后,将Eden和Survivor中还存活的对象一次性地复制到另一块Survivor空间上(这里使用的复制算法进行GC),最后清理掉Eden和刚才用过的Survivor(From)空间。将此时在Survivor空间存活下来的对象的年龄设置为1,以后这些对象每在Survivor区熬过一次GC,它们的年龄就加1,当对象年龄达到某个年龄(默认值为15)时,就会把它们移到老年代中。

在新生代中进行GC时,有可能遇到另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。

方法区

方法区与Java堆一样,是各个线程共享的内存区域。用于存储已被虚拟机加载的类信息常量静态变量。方法区是用永久代这个概念区实现的。1.8版本的HotSpot已经把永久代移除换成元空间

运行时常量池

运行时的常量池是方法区的一部分,用于存储编译期生成的各种字面和符号引用。其实不一定要求编译器时才能产生。运行期间也可能将新的常量放入池中。例如:String.intern();

直接内存

直接内存(DirectMemory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以我们放到这里一起讲解。在JDK 1.4中新加人了NIO (New Input/Output)类,引人了一种基于通道(Channel )与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过-一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

内存分配与回收策略

Eden和from、to它们分配空间大小为8:1:1,新生代的对象主要分配在Eden区上。大对象直接进入老年代虚拟机提供了一个-XX:PretenureSizeThreshold参数,长期存活的对象也可以进入老年代,虚拟机给每个对象定义一个年龄计数器。如果对象第一次出生在Eden并且经过一次Minor GC(新生代回收) 后仍然存活年龄就设为1,每熬过一次Minor GC年龄+1 达到限制年龄就进入老年代(默认15)可以通过-XX:MaxTenuringThreshold设置
前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用了其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况下,就需要老年代进行分配担保,让Survivor无法容纳的对象直接进入老年代。但是前提老年代本身还有足够空间容纳这些对象。但是实际完成内存回收前是无法知道多少对象存活,所以只好取之前每一次回收晋升到老年代对象容量的平均值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多的空间。

-XX:SurvivorRatio 决定新生代 Eden和from、to它们分配空间大小比例

-Xmn 可以限制老年代大小

JDK1.8 JVM 变化

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

实验

工具:Java1.8 、IDEA2019

堆溢出

步骤1、修改JVM配置 Run -> Edit Configurations ->VM optins

-XX:+PrintGCDetails //打印gc日志

-Xms5M // 初始化堆大小为5M

-Xmx5M // 设置堆最大容量为5M

步骤2:运行调试实例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.ArrayList;
import java.util.List;

public class Test {
//-verbose:gc -XX:+PrintGCDetails -Xms5M -Xmx5M
public static void main(String[] args) {
List<String> list =new ArrayList<>();
for(int i=0;i<100000;i++)
{
list.add(new String(i+"test"));
}
}
}


异常:
Exception in thread "main"
java.lang.OutOfMemoryError: GC overhead limit exceeded
at Test.main(Test.java:10)

栈溢出

步骤1、修改JVM配置 Run -> Edit Configurations ->VM optins

-XX:+PrintGCDetails //打印gc日志

-Xss128k //设置栈的大小为128k

步骤2:运行调试实例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Test {
//-Xss128k
private static int StackLength=1;
private static void StackLeak()
{
StackLength++;
StackLeak();
}
public static void main(String[] args) {
try{
StackLeak();
}catch (Throwable e)
{
System.out.println(StackLength);
throw e;
}
}
}

1905
Exception in thread "main" java.lang.StackOverflowError
at Test.StackLeak(Test.java:8)
at Test.StackLeak(Test.java:8)
at Test.StackLeak(Test.java:8)
at Test.StackLeak(Test.java:8)
at Test.StackLeak(Test.java:8)

对象

对象的创建

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

对象的内存布局

对象在内存布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)、和对其数据填充(padding)。

对象头包括两部分信息,第一部分为自身运行时数据(HashCocd、GC分代年龄、锁状态、线程持有锁、偏向线程ID、偏向时间戳等)这部分数据长度在32和64虚拟机中分别为32bit和64bit。官称 “Mark Word”。

第二部分为类型指针,即对象它的类元数据的指针。虚拟机通过这个指针确定这个对象是那个类的实例。如果对象是一个Java数组那对象头必须有一块用于记录数组的长度。

Open JDK markOop.hpp 注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//  32 bits:
// --------
// hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
// size:32 ------------------------------------------>| (CMS free block)
// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
// 64 bits:
// --------
// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
// size:64 ----------------------------------------------------->| (CMS free block)
//
// unused:25 hash:31 -->| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object)
// JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object)
// narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
// unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

对象访问定位

使用句柄访问,Java堆会划分出一块内存为句柄池,refernce中存储的就说对象的句柄地址。

如果用直接指针访问refernce存储的就说对象地址。而HotSpot是使用指针地址,直接访问。

使用句柄的好处是在对象被移动时只需要改变句柄的实例指针。而直接直接方法最大好处是访问速度块,节省了一次指针的时间开销。

垃圾收集器

引言:我们脑容量是有限的,防止“溢出” ,我们的脑袋每天晚上都会清除那些垃圾(短记忆),保留长记忆。

判断对象已经死

如何判断一个对象是否已经死了,可以引用计数算法,给对象加上一个计数器。每当有一个对象引用它计数器+1,引用失效-1。任何时刻计数器为0对象就是不可能被再使用了。

但是有个缺点对象会互相引用,就算类失去引用但是成员变量还在引用所以计数器还是为1,这两块内存就无法回收了。(所以Java没有使用)

可达性分析算法这个算法的基本思所走过的路径称为引用链(ReferenceChain),这个算法的基本思路就是通过–系列的称为“GCRoot”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(ReferenceChain),当–个对象到GCRoots没有任何引用链相连用图论的话来说,就是从GC Root到这个对象不可达时则证明此对象是不可用的。(例如左边是没链接上GC root 所以是被回收的)

要宣布一个对象死亡至少要标记两次。如果对象在进行时可达性算法分析后发现没被GC Root 引用,那它将会被标记一次进行筛选。筛选条件为是否有必要执行finalize()方法,当对象没有覆盖finalize()方法或者已经被虚拟机调用过就没有必要执行。

垃圾回收算法

标记-消除算法

标记算法有两个阶段,首先递归GC root遍历对象把存活的对象标记(或者标记已经死的),然后清除未标记(已标记)的对象。缺点是就效率不高 时间复杂度度为O(n) ,而且会产生大量的碎片空间 会导致频繁的Full GC。

复制算法

首先将可用内存按容量划分大小相等的内存块。每次只使用其中一块,使用完后将还存活的对象放在另一块内存,然后将已使用过的内存清理回收(只需要移动堆顶指针按顺序分配内存就可以解决空间碎片问题)。缺点:会浪费50%内存。

标记-整理算法

标记-整理和标记-清除一样,但不是直接清除而是把存活的对象向另一端移动然后把死亡的对象清除达到内存连续。缺点:会增加停顿时间。

小结

各个算法都有各的好处需要合理使用才达到理想效果和数据结构一样。在新生代中,每次收集垃圾都有大批量对象死去,只有少量存活,那就选用复制算法。而老年代存活率高、没有额外空间对它进行分配担保,就必须用标记算法。

垃圾收集器

收集器就算法的实现。实现算法需要有严格考量,才能保证虚拟机高效运行。可达性分析为了保证“一致性”分析期间会停顿所有Java执行线程。官称 “Stop The World” 。而让让线程中断的思想是设置一个标志,让各个线程执行时区轮询这个标志,发现中断标志为真的时候就自己中断挂起。

Serial收集器

Serial收集器是一个单线程收集器,这里的单线程并不是指的是用一条线程或者一个CPU而是说它执行的时候别的Java线程需要停止,用于虚拟机Client模式的新生代,使用的是复制算法。

原理:eden和from区活跃的对象复制到to区,并清空eden区和from区,如果to区满了,那么部分对象将会被晋升移动到老年代,随后交换from和to区。

ParNew收集器

ParNew收集器是Serial收集器是多线程版本,用于Server模式新生代,使用的是复制算法。

Parallel Scavenge收集器

Parallel Scavenge收集器是一个新生代收集器,使用的是复制算法的收集器,又是并行多线程的收集器。特点是可以控制吞吐量。用停顿时间来换吞吐量在不需要交互的时候是非常不错的选择可以让程序更高效的利用CPU时间。

即吞吐量=运行用户代码时间/ (运行用户代码时间+垃圾收集时间)

-XX:MaxGCPauseMillis 设置停顿时间

-XX:GCTimeRatio 设置吞吐量大小

Serial Old收集器

Serial Old收集器是Serial 收集器老年代版本,同样是单线程收集器,使用的是标记-整理算法。用于Client模式。

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器老年代版本,使用的是标记-整理算法。

CMS(Concurrent Mark Sweep)收集器

CMS是一个追求回收停顿短时间的收集器。使用在网站或B/S系统的服务端上,这类应用重视服务器的反应速度。使用的是标记-清除算法,但它比之前的算法要复杂的多。

步骤:

  1. 初始标记

  2. 并发标记

  3. 重新标记

  4. 并发清除

    初始标记和并发标记需要线程停顿,初始标记只是标记一下GC Root能直接关联到的对象,速度很快。并发标记就是对GC Root 搜索。重新标记是为了修正并发标记期间程序运行时的变动记录,比初始标记阶段需要时间长,但远比并发标记时间短。缺点:无法收集浮动垃圾(并发清除期间程序产生的垃圾)需要下一次清理掉。

    可能出现“Concurrent Mode Failure”导致需要Full GC。CMS不像其他收集器一样等到内存满了才收集,需要预留一部分空间提供并发收集。

    -XX:CMSInitiatingOcupancyFraction 设置百分比来触控CMS的启动

    CMS是用标记-清理算法会导致有碎片空间 +UseCMSCompactAtFullCollection(默认开启) 开关参数会解决这个问题,但是整理内存会让停顿时间变长。参数-XX:CMSFullGCsBeforeCompaction,这个参数是用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,表示每次进人Full GC时都进行碎片整理)。

G1收集器

G1收集器利用的是多CPU多核的硬件环境下来缩短线程停顿时间。部分其他收集器收集需要停顿,而G1收集器可以通过并发让Java程序继续执行。G1收集器看起来是标记-整理,可整体上是复制算法实现的收集器。G1分代收集不需要其他收集器配合就能管理整个GC堆了。

G1收集器把Java堆划分为多个大小相等的区域(Region),虽然也保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离,它们都是一部分Region(不需要连续)的集合。G1收集器是根据收集价值来回收,后台会维护一个优先列表

特点:

  1. 空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。
  2. 可预测停顿,这是G1的另一大优势,降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

(忽略维护优先列表)步骤:

  1. 初始标记

  2. 并发标记

  3. 最终标记

  4. 筛选回收

    初始标记阶段和CMS一样,并且修改TAMS(Next Top at Mark Start)的值让下一个阶段用户并发运行时,能在正确的Region中创建新的对象,这个阶段需要暂停线程,但耗时很短。并发标记是从GC Root找出活的对象,这阶段耗时很长,但可以与用户的线程一同执行。最终标记为了修正并发标记时用户继续运作产生的垃圾。筛选回收将Region进行排序根据用户期望的GC计划进行回收。

在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对

象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。GI中每个Region都有一个与
之对应的RememberedSet,虚拟机发现程序在对Reference类型的数据进行写操作时,会产
生一个WriteBarrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之
中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通.
过CardTable把相关引用信息记录到被引用对象所属的Region的RememberedSet之中。当
进行内存回收时,在GC根节点的枚举范围中加人Remembered Set即可保证不对全堆扫描也
不会有遗漏。

参考书籍《_深入理解Java虚拟机_JVM高级特性与最佳实践》