Fork me on GitHub
ookamiAntD's Blog

JVM知识杂汇

前言

想要深刻地理解Java, 那么就要深入地理解底层——JVM(Java Virtual Machine | Java虚拟机).
JVM(Java Virtual Machine)Java 虚拟机是整个 java 平台的基石, 是 java 系统实现硬件无关与操作系统无关的关键部分, 是保障用户机器免于恶意代码损害的屏障. Java开发人员不需要了解JVM是如何工作的, 但是, 了解 JVM 有助于我们更好的开(通)发(过) java(公司) 程(面)序(试)
博主经过一番查阅, 找到了自认为写的好的一些文章, 并记录总结, 方便不定时的看.
希望每次看都会有新的领悟, 不断提高自己.

Java虚拟机架构

什么是JVM

要想说明白什么 JVM 就不得不提另外两个概念, JRE 和 JDK, 初学者总是把这几个概念搞混.

JVM, JRE, JDK 都是 Java 语言的支柱, 他们分工协作. 但不同的是 JDK 和 JRE 是真实存在的, 而 JVM 是一个抽象的概念, 并不真实存在.

JDK

JDK(Java Development Kit) 是 Java 语言的软件开发工具包(SDK). JDK 物理存在, 是 programming tools、JRE 和 JVM 的一个集合.

JRE

JRE(Java Runtime Environment)Java 运行时环境, JRE 物理存在, 主要由Java API 和 JVM 组成, 提供了用于执行 Java 应用程序最低要求的环境.

JVM(Java Virtual Machine)

JVM(Java Virtual Machine) 是一种软件实现, 执行像物理机程序的机器(即电脑).
本来, Java被设计基于从物理机器分离实现WORA( 写一次, 随处运行 )的虚拟机上运行, 虽然这个目标已经几乎被遗忘.
JVM 并不是专为 Java 所实现的运行时, 实际上只要有其他编程语言的编译器能生成正确 Java bytecode 文件, 则这个语言也能实现在JVM上运行.
因此, JVM 通过执行 Java bytecode 可以使 java 代码在不改变的情况下运行在各种硬件之上.

JVM实现了Java语言最重要的特征: 即平台无关性.
平台无关性原理: 编译后的 Java程序(.class文件)由JVM执行. JVM屏蔽了与具体平台相关的信息, 使程序可以在多种平台上不加修改地运行. Java虚拟机在执行字节码时, 把字节码解释成具体平台上的机器指令执行. 因此实现Java平台无关性.

JVM结构图


JVM = 类加载器 classloader+ 执行引擎 executionengine + 运行时数据区域 runtime data area
首先Java源代码文件被Java编译器编译为字节码文件, 然后JVM中的类加载器加载完毕之后, 交由JVM执行引擎执行. 在整个程序执行过程中, JVM中的运行时数据区(内存)会用来存储程序执行期间需要用到的数据和相关信息.
因此, 在Java中我们常常说到的内存管理就是针对这段空间进行管理(如何分配和回收内存空间).

ClassLoader

ClassLoader把硬盘上的class文件加载到JVM中的运行时数据区域, 但是它不负责这个类文件能否执行, 而这个是执行引擎负责的.

执行引擎

作用: 执行字节码, 或者执行本地方法.

Runtime DataArea

JVM运行时数据区 (JVM RuntimeArea)其实就是指 JVM在运行期间, 其对JVM内存空间的划分和分配. JVM在运行时将数据划分为了以下几个区域来存储.
程序员写的所有程序都被加载到运行时数据区域中.

(图注: JDK1.7已经把常量池转移到堆里面了!

PC寄存器(The pc Register)

(1)每一个Java线程都有一个PC寄存器, 用以记录当前执行到哪个指令.
(2)用于存储每个线程下一步将执行的JVM指令, 如该方法是Java方法, 则记录的是正在执行的虚拟机字节码地址, 如该方法为native的, 则计数器值为空.
(3)此内存区域是唯一一个在JVM中没有规定任何OutOfMemoryError情况的区域.

JVM栈(Java Virtual Machine Stacks)

(1)JVM栈是线程私有的, 并且生命周期与线程相同. 并且当线程运行完毕后, 相应内存也就被自动回收.
(2)栈里面存放的元素叫栈帧, 每个函数从调用到执行结束, 其实是对应一个栈帧的入栈和出栈.
(栈帧好像很复杂的样子, 其实它很简单!)它里面具体存放的是执行的函数的一些数据, 如局部变量、操作数栈(执行引擎计算时需要), 方法出口等等.
(3)这个区域可能有两种异常: 如果线程请求的栈深度大于虚拟机所允许的深度, 将抛出StackOverflowError异常(如: 将一个函数反复递归自己, 最终会出现这种异常). 如果JVM栈可以动态扩展(大部分JVM是可以的), 当扩展时无法申请到足够内存则抛出OutOfMemoryError异常.

下面来实现一个栈溢出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package org.antd.test;

public class StackOverFlowMock {
int num;
public int getNum() {
return num;
}
public void stackOver(){
num++;
stackOver();
}
public static void main(String[] args) {
StackOverFlowMock stackOverFlowTest = new StackOverFlowMock();
try {
stackOverFlowTest.stackOver();
} catch (Throwable t) {
System.out.println("迭代深度: "+stackOverFlowTest.getNum());
t.printStackTrace();
}
}

}

输出为:

1
2
3
4
5
6
7
8
迭代深度: 17781
java.lang.StackOverflowError
at org.antd.test.StackOverFlowMock.stackOver(StackOverFlowMock.java:10)
at org.antd.test.StackOverFlowMock.stackOver(StackOverFlowMock.java:10)
at org.antd.test.StackOverFlowMock.stackOver(StackOverFlowMock.java:10)
at org.antd.test.StackOverFlowMock.stackOver(StackOverFlowMock.java:10)
at org.antd.test.StackOverFlowMock.stackOver(StackOverFlowMock.java:10)
at org.antd.test.StackOverFlowMock.stackOver(StackOverFlowMock.java:10)

本地方法栈(Native Method Stacks)

(1)本地方法栈与虚拟机栈所发挥的作用很相似, 他们的区别在于虚拟机栈为执行Java代码方法服务, 而本地方法栈是为Native方法服务.
(2)和JVM栈一样, 这个区域也会抛出StackOverflowErrorOutOfMemoryError异常.

方法区(Method Area)

(1)在方法区中, 存储了每个类的信息静态变量等. 如, 当程序中通过getNameisInterface等方法来获取信息时, 这些数据来源于方法区.
(2)方法区域是全局共享的, 比如每个线程都可以访问同一个类的静态变量.
(3)由于使用反射机制的原因, 虚拟机很难推测哪个类信息不再使用, 因此这块区域的回收很难!另外, 对这块区域主要是针对常量池回收, 值得注意的是JDK1.7已经把常量池转移到堆里面了.
(4)同样, 当方法区无法满足内存分配需求时, 会抛出OutOfMemoryError. 下面演示一下造成方法区内的OOM场景.
执行之前, 可以把虚拟机的参数-XXpermSize-XX: MaxPermSize限制方法区大小.
那么实现一下OOM:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class HeapOomMock {
public static void main(String[] args) {
List<byte[]> list = new ArrayList<byte[]>();
int i = 0;
boolean flag = true;
while (flag) {
try {
i++;
list.add(new byte[1024 * 1024]);// 每次增加一个1M大小的数组对象
} catch (Throwable e) {
flag = false;
System.out.println("count=" + i);// 记录运行的次数
e.printStackTrace();
}
}
}
}

控制台输出:

1
2
3
count=3422
java.lang.OutOfMemoryError: Java heap space
at org.antd.test.HeapOomMock.main(HeapOomMock.java:14)

运行时常量池(Runtime Constant Pool)

(1)存放类中固定的常量信息、方法引用信息等, 其空间从方法区域(JDK1.7后为堆空间)中分配.
(2)Class文件中除了有类的版本、字段、方法、接口等描述等信息外, 还有就是常量表(constant_pool table), 用于存放编译期已可知的常量, 这部分内容将在类加载后进入方法区(永久代)存放. 但是Java语言并不要求常量一定只有编译期预置入Class的常量表的内容才能进入方法区常量池, 运行期间也可将新内容放入常量池(最典型的String.intern()方法).
(3)当常量池无法在申请到内存时会抛出OutOfMemoryError异常.
再来一段代码展示以下:

1
2
3
4
5
6
//不断将字符串添加到常量池, 最终导致内存不足抛出方法区的OOM 
List<String> list =new ArrayList<String>();
int i =0;
while(true){
list.add(String.valueOf(i).intern());
}

String的intern函数的作用就不多赘述了, 在这篇博文了解String类的intern()方法有所介绍. 关于JDK1.6和JDK1.7之后常量池位置的变化对该函数的影响, 也在链接文中阐述了.

Java堆

(1)Java堆是JVM所管理的最大的一块内存. 它是被所有线程共享的一块内存区域, 在虚拟机启动时创建.
(2)几乎所有的实例对象都是在这块区域中存放. (JIT编译器貌似不是这样的).
(3)Java堆是垃圾搜集管理的主要战场. 所有Java堆可以细分为: 新生代老年代. 再细致分就是把新生代分为: Eden空间FromSurvivor空间To Survivor空间.
(4)根据Java虚拟机规范的规定, Java堆可以处于物理上不连续的内存空间中, 只要逻辑上是连续的即可, 就像我们的磁盘空间一样. 如果在堆中没有内存完成实例分配, 并且堆也无法再扩展时, 将会抛出OutOfMemoryError异常.

堆和栈的区别

这是一个非常常见的面试题, 主要从以下几个方面来回答.

各司其职

最主要的区别就是栈内存用来存储局部变量和方法调用.
而堆内存用来存储Java中的对象. 无论是成员变量、局部变量还是类变量, 它们指向的对象都存储在堆内存中.

空间大小

栈的内存要远远小于堆内存, 如果你使用递归的话, 那么你的栈很快就会充满并产生StackOverFlowError.

独有还是共享

栈内存归属于线程的私有内存, 每个线程都会有一个栈内存, 其存储的变量只能在其所属线程中可见.
而堆内存中的对象对所有线程可见, 可以被所有线程访问.

异常错误

如果线程请求的栈深度大于虚拟机所允许的深度, 将抛出StackOverflowError异常.
如果JVM栈可以动态扩展(大部分JVM是可以的), 当扩展时无法申请到足够内存则抛出OutOfMemoryError异常.
而堆内存没有可用的空间存储生成的对象, JVM会抛出java.lang.OutOfMemoryError.

以上便是关于JVM架构的相关知识.

内存管理和垃圾回收

何为GC

Java与C语言相比的一个优势是, 可以通过自己的JVM自动分配和回收内存空间.
垃圾回收机制是由垃圾搜集器Garbage Collection来实现的, GC是后台一个低优先级的守护进程. 在内存中低到一定限度时才会自动运行, 因此垃圾回收的时间是不确定的.

为何要这样设计: 因为GC也要消耗CPU等资源, 如果GC执行过于频繁会对Java的程序的执行产生较大的影响, 因此实行不定期的GC.

与GC有关的是: JVM运行时数据区中的堆(对象实例会存储在这里)和 gabagecollector方法.
垃圾回收GC只能回收通过new关键字申请的内存(在堆上), 但是堆上的内存并不完全是通过new申请分配的. 还有一些本地方法, 这些内存如果不手动释放, 就会导致内存泄露, 所以需要在finalize中用本地方法(nativemethod)如free操作等, 再使用gc方法.

1
System.gc();

何为垃圾

Java中那些不可达的对象就会变成垃圾. 对象之间的引用可以抽象成树形结构, 通过树根(GC Roots)作为起点, 从这些树根往下搜索, 搜索走过的链称为引用链.
当一个对象到GC Roots没有任何引用链相连时, 则证明这个对象为可回收的对象.
可以作为GC Roots的主要有以下几种:
(1)栈帧中的本地变量表所引用的对象.
(2)方法区中类静态属性和常量引用的对象.
(3)本地方法栈中JNI(Native方法)引用的对象.

1
2
3
4
5
6
//垃圾产生的情况举例: 
//1.改变对象的引用, 如置为null或者指向其他对象
Object obj1 = new Object();
Object obj2 = new Object();
obj1 = obj2; //obj1成为垃圾
obj1 = obj2 = null ; //obj2成为垃圾

1
2
3
4
5
//2.引用类型 
//第2句在内存不足的情况下会将String对象判定为可回收对象, 第3句无论什么情况下String对象都会被判定为可回收对象
String str = new String("hello");
SoftReference<String> sr = new SoftReference<String>(new String("java"));
WeakReference<String> wr = new WeakReference<String>(new String("world"));
1
2
3
4
5
//3.循环每执行完一次, 生成的Object对象都会成为可回收的对象 
for(int i=0;i<10;i++) {
Object obj = new Object();
System.out.println(obj.getClass());
}
1
2
3
4
5
6
7
//4.类嵌套 
class A{
A a;
}
A x = new A();//分配了一个空间
x.a = new A();//又分配了一个空间
x = null;//产生两个垃圾
1
2
3
4
5
6
7
8
9
10
11
//5.线程中的垃圾 
calss A implements Runnable{
void run(){
//....
}
}

//main
A x = new A();
x.start();
x=null; //线程执行完成后x对象才被认定为垃圾

四种引用类型

强引用

1
Object obj = new Object();

这里的obj引用便是一个强引用, 强引用不会被GC回收. 即使抛出OutOfMemoryError错误, 使程序异常终止.

软引用

如果内存空间足够, 垃圾回收器就不会回收它, 如果内存空间不足了, 就会回收这些对象的内存. 软引用可用来实现内存敏感的高速缓存.
软引用可以和一个引用队列(ReferenceQueue)联合使用, 如果软引用所引用的对象被垃圾回收, Java虚拟机就会把这个软引用加入到与之关联的引用队列中.

弱引用

弱引用与软引用的区别在于: 垃圾回收器一旦发现了弱引用的对象, 不管当前内存空间足够与否, 都会回收它的内存. 不过由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些弱引用的对象.
弱引用可以和一个引用队列(ReferenceQueue)联合使用, 如果弱引用所引用的对象被垃圾回收, Java虚拟机就会把这个弱引用加入到与之关联的引用队列中.

虚引用

虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用. 当垃圾回收器发现一个对象有虚引用时, 就会把这个虚引用对象加入到与之关联的引用队列中. 此时该对象并没有被GC回收. 而是要等到引用队列被真正的处理后才会被回收.
程序可以通过判断引用队列中是否已经加入了虚引用, 来了解被引用的对象是否将要被垃圾回收.
(由于Object.finalize()方法的不安全性、低效性, 常常使用虚引用完成对象回收前的资源释放工作. )
这里特别需要注意: 当JVM将虚引用插入到引用队列的时候, 虚引用执行的对象内存还是存在的. 但是PhantomReference并没有暴露API返回对象. 所以如果想做清理工作, 需要继承PhantomReference类, 以便访问它指向的对象. 如NIO直接内存的自动回收, 就使用到了sun.misc.Cleaner.

典型的垃圾回收算法

在确定了哪些垃圾可以被回收后, 垃圾搜集器要做的事情就是开始进行垃圾回收, 但是这里面涉及到一个问题是: 如何高效地进行垃圾回收.
下面讨论几种常见的垃圾搜集算法.

Mark-Sweep(标记-清除)算法

标记-清除算法分为两个阶段: 标记阶段和清除阶段.
标记阶段的任务是标记出所有需要被回收的对象, 清除阶段就是回收被标记的对象所占用的空间.
标记-清除算法实现起来比较容易, 但是有一个比较严重的问题就是容易产生内存碎片, 碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发GC.

Copying(复制)算法

Copying算法将可用内存按容量划分为大小相等的两块, 每次只使用其中的一块. 当这一块的内存用完了, 就将还存活着的对象复制到另外一块上面, 然后再把第一块内存上的空间一次清理掉, 这样就不容易出现内存碎片的问题, 并且运行高效.
但是该算法导致能够使用的内存缩减到原来的一半. 而且, 该算法的效率跟存活对象的数目多少有很大的关系, 如果存活对象很多, 那么Copying算法的效率将会大大降低. (这也是为什么后面提到的新生代采用Copying算法)

Mark-Compact(标记-整理)算法

为了解决Copying算法的缺陷, 充分利用内存空间, 提出了Mark-Compact算法.
该算法标记阶段标记出所有需要被回收的对象, 但是在完成标记之后不是直接清理可回收对象, 而是将存活的对象都移向一端, 然后清理掉端边界以外的所有内存只留下存活对象).

以上三种算法对比

它们的共同点主要有以下两点:
1、三个算法都基于根搜索算法去判断一个对象是否应该被回收, 而支撑根搜索算法可以正常工作的理论依据, 就是语法中变量作用域的相关内容. 因此, 要想防止内存泄露, 最根本的办法就是掌握好变量作用域, 而不应该使用前面内存管理杂谈一章中所提到的C/C++式内存管理方式.
2、在GC线程开启时, 或者说GC过程开始时, 它们都要暂停应用程序(stop the world).
它们的区别按照下面几点来给各位展示. (>表示前者要优于后者, =表示两者效果一样)
效率: 复制算法>标记-整理算法>标记-清除算法(此处的效率只是简单的对比时间复杂度, 实际情况不一定如此).
内存整齐度: 复制算法=标记-整理算法>标记-清除算法.
内存利用率: 标记-整理算法=标记-清除算法>复制算法.
可以看到标记-清除算法是比较落后的算法了, 但是后两种算法却是在此基础上建立的, 俗话说“吃水不忘挖井人”, 因此各位也莫要忘记了标记-清除这一算法前辈. 而且, 在某些时候, 标记-清除也会有用武之地.

到此我们已经将三个算法了解清楚了, 可以看出, 效率上来说, 复制算法是当之无愧的老大, 但是却浪费了太多内存, 而为了尽量兼顾上面所提到的三个指标, 标记-整理算法相对来说更平滑一些, 但效率上依然不尽如人意, 它比复制算法多了一个标记的阶段, 又比标记-清除多了一个整理内存的过程.
难道就没有一种最优算法吗?
当然是没有的, 这个世界是公平的, 任何东西都有两面性, 试想一下, 你怎么可能找到一个又漂亮又勤快又有钱又通情达理, 性格又合适, 家境也合适, 身高长相等等等等都合适的女人?就算你找到了, 至少有一点这个女人也肯定不满足, 那就是她不会爱上你. 你是不是想说你比博主强太多了, 那博主只想对你说, 高富帅是不会爬在电脑前看技术文章的, 0.0.
但是古人就是给力, 古人说了, 找媳妇不一定要找最好的, 而是要找最合适的, 听完这句话, 瞬间感觉世界美好了许多.
算法也是一样的, 没有最好的算法, 只有最合适的算法.
既然这三种算法都各有缺陷, 高人们自然不会容许这种情况发生. 因此, 高人们提出可以根据对象的不同特性, 使用不同的算法处理, 类似于萝卜白菜各有所爱的原理. 于是奇迹发生了, 高人们终于找到了GC算法中的神级算法—–分代搜集算法.

Generational Collection(分代搜集)算法

分代搜集算法是针对对象的不同特性, 而使用适合的算法, 这里面并没有实际上的新算法产生. 与其说分代搜集算法是第四个算法, 不如说它是对前三个算法的实际应用.
分代搜集算法是目前大部分JVM的垃圾搜集器采用的算法.
它的核心思想是将堆区划分为老年代(Tenured Generation)和新生代(Young Generation), 老年代的特点是每次垃圾搜集时只有少量对象需要被回收, 而新生代的特点是每次垃圾回收时都有大量的对象需要被回收, 那么就可以在不同代的采取不同的最适合的搜集算法.

目前大部分垃圾搜集器对于新生代都采取Copying算法, 因为新生代中每次垃圾回收都要回收大部分对象, 也就是说需要复制的操作次数较少, 该算法效率在新生代也较高. 但是实际中并不是按照1: 1的比例来划分新生代的空间的, 一般来说是将新生代划分为一块较大的Eden空间两块较小的Survivor空间, 每次使用Eden空间和其中的一块Survivor空间, 当进行回收时, 将还存活的对象复制到另一块Survivor空间中, 然后清理掉Eden和A空间. 在进行了第一次GC之后, 使用的便是Eden space和B空间了, 下次GC时会将存活对象复制到A空间, 如此反复循环.

当对象在Survivor区躲过一次GC的话, 其对象年龄便会加1, 默认情况下, 对象年龄达到15时, 就会移动到老年代中. 一般来说, 大对象会被直接分配到老年代, 所谓的大对象是指需要大量连续存储空间的对象, 最常见的一种大对象就是大数组, 比如: byte[] data = new byte[4*1024*1024].
当然分配的规则并不是百分之百固定的, 这要取决于当前使用的是哪种垃圾搜集器组合和JVM的相关参数. 这些搬运工作都是GC完成的, GC不仅负责在Heap中搬运实例, 同时负责回收存储空间.
最后, 因为每次回收都只回收少量对象, 所以老年代一般使用的是标记整理算法.

注意, 在方法区中有一个永久代(Permanet Generation), 它用来存储class类、常量、方法描述等. 对永久代的回收主要回收两部分内容: 废弃常量无用的类.

有关查看垃圾回收信息的JVM常见配置方式:

1
-XX:+PrintGCDetails

最后介绍一下有关堆的JVM常见配置方式:

1
2
3
4
5
6
7
-Xss //选置栈内存的大小  
-Xms: //初始堆大小
-Xmx: //最大堆大小
-XX:NewSize=n: //设置年轻代大小
-XX:NewRatio=n: //设置年轻代和年老代的比值. 比如设置为3, 表示年轻代与年老代比值为1: 3
-XX:SurvivorRatio=n: //年轻代中Eden区与两个Survivor区的比值. 注意Survivor区有两个. 比如设置为3, 表示Eden: Survivor=3: 2, 一个Survivor区占整个年轻代的1/5.
-XX:MaxPermSize=n: //设置持久代大小

典型的垃圾回收器

垃圾搜集算法是内存回收的理论基础, 而垃圾搜集器就是内存回收的具体实现.
下面介绍一下HotSpot(JDK 7)虚拟机提供的几种垃圾搜集器, 用户可以根据自己的需求组合出各个年代使用的搜集器.

Serial&Serial Old

SerialSerial Old搜集器是最基本最古老的搜集器, 是一个单线程搜集器, 并且在它进行垃圾搜集时, 必须暂停所有用户线程. Serial搜集器是针对新生代的搜集器, 采用的是Copying算法, Serial Old搜集器是针对老年代的搜集器, 采用的是Mark-Compact算法. 它的优点是实现简单高效, 但是缺点是会给用户带来停顿.

ParNew

ParNew搜集器是Serial搜集器的多线程版本, 使用多个线程进行垃圾搜集.

Parallel Scavenge

Parallel Scavenge搜集器是一个新生代的多线程搜集器(并行搜集器), 它在回收期间不需要暂停其他用户线程, 其采用的是Copying算法, 该搜集器与前两个搜集器有所不同, 它主要是为了达到一个可控的吞吐量.

Parallel Old

Parallel OldParallel Scavenge搜集器的老年代版本(并行搜集器), 使用多线程和Mark-Compact算法.

CMS

CMS(Current Mark Sweep)搜集器是一种以获取最短回收停顿时间为目标的搜集器, 它是一种并发搜集器, 采用的是Mark-Sweep算法.

G1

G1搜集器是当今搜集器技术发展最前沿的成果, 它是一款面向服务端应用的搜集器, 它能充分利用多CPU多核环境. 因此它是一款并行与并发搜集器, 并且它能建立可预测的停顿时间模型.
最后介绍一下有关搜集器设置的JVM常见配置方式:

1
2
3
4
5
6
7
8
9
10
11
-XX:+UseSerialGC: //设置串行搜集器  
-XX:+UseParallelGC: //设置并行搜集器
-XX:+UseParalledlOldGC: //设置并行年老代搜集器
-XX:+UseConcMarkSweepGC: //设置并发搜集器
//并行搜集器设置
-XX:ParallelGCThreads=n: //设置并行搜集器搜集时使用的CPU数, 并行搜集线程数
-XX:MaxGCPauseMillis=n: //设置并行搜集最大暂停时间
-XX:GCTimeRatio=n: //设置垃圾回收时间占程序运行时间的百分比, 公式为1/(1+n)
//并发搜集器设置
-XX:+CMSIncrementalMode: //设置为增量模式. 适用于单CPU情况
-XX:ParallelGCThreads=n: //设置并发搜集器年轻代搜集方式为并行搜集时, 使用的CPU数. 并行搜集线程数

Java类加载机制总结

类加载器的组织结构

类加载器 ClassLoader是具有层次结构的, 也就是父子关系. 其中, Bootstrap是所有类加载器的父亲.
(1)Bootstrapclass loader: 启动类加载器
当运行Java虚拟机时, 这个类加载器被创建, 它负责加载虚拟机的核心类库, 如java.lang.*等.
(2)Extensionclass loader: 标准扩展类加载器
用于加载除了基本 API之外的一些拓展类.
(3)AppClassLoader: 加载应用程序和程序员自定义的类.
运行下面的程序, 结果也显示出来了:

从运行结果可以看出加载器之间的父子关系, ExtClassLoader的父Loader返回了null
原因是BootstrapLoader(启动类加载器)是用C语言实现的, 找不到一个确定的返回父Loader的方式.

类的加载机制

类被加载到虚拟机内存包括加载链接初始化几个阶段. 其中链接又细化分为验证准备解析.
这里需要注意的是, 解析阶段在某些情况下可以在初始化阶段之后再开始, 这是为了支持Java的运行时绑定. 各个阶段的作用整理如下:

加载阶段

加载阶段可以使用系统提供的类加载器(ClassLoader)来完成, 也可以由用户自定义的类加载器完成, 开发人员可以通过定义类加载器去控制字节流的获取方式.
(1)通过类的全名产生对应类的二进制数据流(注意, 若未找到该类文件, 只有在类实际使用时抛出错误).
(2)分析并将这些二进制数据流转换为方法区的运行时数据结构.
(3)创建代表这个类的java.lang.Class对象. 作为方法区这些数据的访问入口.

链接阶段(实现 Java 的动态性的重要一步)

(1)验证: 主要的目的是确保class文件的字节流中包含的信息符合当前虚拟机的要求, 并且不会危害虚拟机自身安全. 验证点可能包括: class文件格式规范、这个类是否继承了不允许被继承的类(被final修饰的)、如果这个类的父类是抽象类, 是否实现了起父类或接口中要求实现的所有方法、不能把一个父类对象赋值给子类数据类型、方法的访问性(private、protected、public、default)是否可被当前类访问等等.
(2)准备: 准备阶段为类的成员变量分配内存空间并设置类变量初始值的阶段, 这些变量所使用的内存都在方法区中分配. 所有原始类型的值都为0. 如float为0f、 int为0、boolean为0、引用类型为null.
(3)解析: 解析阶段是把虚拟机中常量池的符号引用替换为直接引用的过程.

初始化

类初始化时类加载的最后一步, 前面除了加载阶段用户可以通过自定义类加载器参与以外, 其余都是虚拟机主导和控制. 到了初始化阶段, 才是真正执行类中定义Java程序代码. 初始化阶段, 根据程序中的定制, 初始化类变量.
初始化过程其实是执行类构造器方法的过程.
类构造器方法是由编译器自动搜集类中所有类变量的赋值动作和静态语句块中的语句合并产生的. )

1
2
3
4
5
6
7
8
//静态语句块中只能访问定义在静态语句块之前的变量, 定义在它之后的变量可以赋值, 但不能访问  
public class Test{
static{
i=0;//給变量赋值, 可以通过编译
System.out.print(i);//这句编译器会提示非法向前引用
}
static int i=1;
}

初始化过程会被触发执行的条件汇总:
(1)使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候, 以及调用类的静态方法*的时候.
(2)
对类进行反射调用的时候.
(3)当初始化一个类的时候, 如果发现其
父类还没有进行过初始化, 则进行父类的初始化.
(4)JVM启动时, 用
户指定执行的主类**(包含main方法所在类), 虚拟机会先初始化这个类.

关于构造器方法拓展知识】(可以不看)
(1)类构造器<clinit>()方法与类的构造函数不同, 它不需要显式调用父类构造, 虚拟机会保证在子类<clinit>()方法执行之前, 父类的<clinit>()方法已经执行完毕. 因此在虚拟机中的第一个执行的<clinit>()方法的类肯定是java.lang.Object.
(2)由于父类的<clinit>()方法先执行, 也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作.
(3)<clinit>()方法对于类或接口来说并不是必须的, 如果一个类中没有静态语句, 也没有变量赋值的操作, 那么编译器可以不为这个类生成<clinit>()方法.
(4)接口中不能使用静态语句块, 和类不同的是, 执行接口的<clinit>()方法不需要先执行父接口<clinit>()方法. 只有当父接口中定义的变量被使用时, 父接口才会被初始化. 另外, 接口的实现类在初始化时也一样不会执行接口的<clinit>()方法.
(5)虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确加锁和同步, 可能会导致阻塞.

类的整个加载过程触发的的三种方式

遇到 newgetstaticputstaticinvokestatic 这4条字节码指令时,如果类没有进行过初始化, 则需要先触发其初始化, 生成这4条指令的最常见的 Java代码场景是:

  • new 关键字创建一个类的实例.
  • 调用 Class.forName() 方法, 通过反射加载类.
  • 调用某个ClassLoader实例的loadClass()方法.

三者的区别汇总如下:
(1)方法1和2都是使用的当前类加载器this.getClass.getClassLoader). 方法3由用户指定类加载器并且加载的类与当前类分属不同的命名空间.
(2)方法1是静态加载, 2、3是动态加载.
(3)对于两种动态加载, 区别如下.

1
2
3
Class.forName(className);  
//实际上是调用的是:
Class.forName(className, true, this.getClass().getClassLoader());//第二个参数设置Class被loading后是不是必须被初始化, 默认初始化
1
2
3
ClassLoader.loadClass(className);  
//实际上调用的是:
ClassLoader.loadClass(name, false);//第二个参数指Class是否被链接, 默认为false

通过上面的描述, 如果程序依赖于Class是否被初始化, 就必须用Class.forName(name)

自定义类加载器

为什么需要自定义类加载器

网上的大部分自定义类加载器文章, 几乎都是贴一段实现代码, 然后分析一两句自定义ClassLoader的原理. 但是个人觉得首先得把为什么需要自定义加载器这个问题搞清楚, 因为如果不明白它的作用的情况下, 还要去学习它显然是很让人困惑的.
首先介绍自定义类的应用场景:
(1)加密: Java代码可以轻易的被反编译, 如果你需要把自己的代码进行加密以防止反编译, 可以先将编译后的代码用某种加密算法加密, 类加密后就不能再用Java的ClassLoader去加载类了, 这时就需要自定义ClassLoader在加载类的时候先解密类, 然后再加载.
(2)从非标准的来源加载代码: 如果你的字节码是放在数据库、甚至是在云端, 就可以自定义类加载器, 从指定的来源加载类.
(3)以上两种情况在实际中的综合运用: 比如你的应用需要通过网络来传输 Java 类的字节码, 为了安全性, 这些字节码经过了加密处理. 这个时候你就需要自定义类加载器来从某个网络地址上读取加密后的字节代码, 接着进行解密和验证, 最后定义出在Java虚拟机中运行的类.

双亲委派模型

在实现自己的ClassLoader之前, 我们先了解一下系统是如何加载类的, 那么就不得不介绍双亲委派模型的特点和实现过程.
双亲委派模型特点:
该模型要求除了顶层的Bootstrapclassloader启动类加载器外, 其余的类加载器都应当有自己的父类加载器. 子类加载器和父类加载器不是以继承(Inheritance)的关系来实现, 而是通过组合(Composition)关系复用父加载器的代码.

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
27
28
29
//双亲委派模型的工作过程源码  
protected synchronized Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// First, check if the class has already been loaded
Class c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
//父类加载器无法完成类加载请求
}
if (c == null) {
// If still not found, then invoke findClass in order to find the class
//子加载器进行类加载
c = findClass(name);
}
}
if (resolve) {//判断是否需要链接过程, 参数传入
resolveClass(c);
}
return c;
}

双亲委派模型的工作过程如下:
(1)代码中一开始的判空操作是当前 ClassLoader从自己已经加载的类中查询是否此类已经加载, 如果已经加载则直接返回原来已经加载的类. (每个类加载器都有自己的加载缓存, 当一个类被加载了以后就会放入缓存, 等下次加载的时候就可以直接返回)
(2)当前 ClassLoader的缓存中没有找到被加载的类的时候, 它自己不会尝试去加载该类, 而是委托父类加载器去加载, 如代码c = parent.loadClass(name, false)所示(父类加载器采用同样的策略, 递归了loadClass函数), 首先查看自己的缓存, 没有就委托父类的父类去加载, 一直到 BootStrap ClassLoader. 如代码所示, 如果父加载器为空则默认使用启动类加载器(BootStrap ClassLoader)作为父加载器去加载, 如代码findBootstrapClassOrNull(name)所示(为何父类为BootStrap ClassLoader会返回空, 原因在Java类加载机制总结中介绍过了).
(3)如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class), 会使用ExtClassLoader来尝试加载; 若ExtClassLoader也加载失败, 则会使用AppClassLoader来加载, 如果AppClassLoader也加载失败, 则会抛出ClassNotFoundException. 最后再调用当前加载器的findClass()方法进行加载.

双亲委派模型的好处:
(1)主要是为了安全性, 避免用户自己编写的类动态替换Java的一些核心类, 比如 String.
(2)同时也避免重复加载, 因为 JVM中区分不同类, 不仅仅是根据类名, 相同的class文件被不同的 ClassLoader加载就是不同的两个类.

自定义类加载器

(1)从上面源码可以看出, 在调用loadClass方法时, 会先根据委派模型在父加载器中加载, 如果加载失败, 则会调用自己的findClass方法来完成加载.
(2)因此我们自定义的类加载器只需要继承ClassLoader, 并覆盖findClass方法.
(3)下面是一个实际例子, 在该例中我们用自定义的类加载器去加载我们事先准备好的class文件.

自定义一个People.java类做例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class People {  
//该类写在记事本里, 在用javac命令行编译成class文件, 放在d盘根目录下
private String name;
public People() {}

public People(String name) {
this.name = name;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public String toString() {
return "I am a people, my name is " + name;
}

}

自定义类加载器

自定义一个类加载器, 需要继承ClassLoader类, 并实现findClass方法. 其中defineClass方法可以把二进制流字节组成的文件转换为一个java.lang.Class(只要二进制字节流的内容符合Class文件规范).

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class MyClassLoader extends ClassLoader  
{
public MyClassLoader()
{

}

public MyClassLoader(ClassLoader parent)
{
super(parent);
}

protected Class<?> findClass(String name) throws ClassNotFoundException
{
File file = new File("D:/People.class");
try{
byte[] bytes = getClassBytes(file);
//defineClass方法可以把二进制流字节组成的文件转换为一个java.lang.Class
Class<?> c = this.defineClass(name, bytes, 0, bytes.length);
return c;
}
catch (Exception e)
{
e.printStackTrace();
}

return super.findClass(name);
}

private byte[] getClassBytes(File file) throws Exception
{
// 这里要读入.class的字节, 因此要使用字节流
FileInputStream fis = new FileInputStream(file);
FileChannel fc = fis.getChannel();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
WritableByteChannel wbc = Channels.newChannel(baos);
ByteBuffer by = ByteBuffer.allocate(1024);

while (true){
int i = fc.read(by);
if (i == 0 || i == -1)
break;
by.flip();
wbc.write(by);
by.clear();
}
fis.close();
return baos.toByteArray();
}
}

在主函数里使用

1
2
3
4
5
6
MyClassLoader mcl = new MyClassLoader();   
Class<?> clazz = Class.forName("People", true, mcl);
Object obj = clazz.newInstance();

System.out.println(obj);
System.out.println(obj.getClass().getClassLoader());//打印出我们的自定义类加载器

运行结果

至此关于自定义ClassLoader的内容总结完毕.

看懂 JavaGC 日志

JVM GC 相关的参数

1
2
3
4
5
-XX:+PrintGC 输出 GC 日志
-XX:+PrintGCDetails 输出 GC 的详细日志
-XX:+PrintGCTimeStamps 输出 GC 的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出 GC 的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行 GC 的前后打印出堆的信息-Xloggc:D:/gc.log 日志文件的输出路径

示例

比如在某个应用中,配置:

1
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:D:/gc.log

启动后打印如下 GC 日志:

YongGC

2019-04-18T14:52:06.790+0800: 2.653: [GC (Allocation Failure) [PSYoungGen: 33280K->5113K(38400K)] 33280K->5848K(125952K), 0.0095764 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

含义:

2019-04-18T14:52:06.790+0800(当前时间戳): 2.653(应用启动基准时间): [GC (Allocation Failure) [PSYoungGen(表示 Young GC): 33280K(年轻代回收前大小)->5113K(年轻代回收后大小)(38400K(年轻代总大小))] 33280K(整个堆回收前大小)->5848K(整个堆回收后大小)(125952K(堆总大小)), 0.0095764(耗时) secs] [Times: user=0.00(用户耗时) sys=0.00(系统耗时), real=0.01(实际耗时) secs]

Full GC

2019-04-18T14:52:15.359+0800: 11.222: [Full GC (Metadata GC Threshold) [PSYoungGen: 6129K->0K(143360K)] [ParOldGen: 13088K->13236K(55808K)] 19218K->13236K(199168K), [Metaspace: 20856K->20856K(1069056K)], 0.1216713 secs] [Times: user=0.44 sys=0.02, real=0.12 secs]

含义:

2019-04-18T14:52:15.359+0800(当前时间戳): 11.222(应用启动基准时间): [Full GC (Metadata GC Threshold) [PSYoungGen: 6129K(年轻代回收前大小)->0K(年轻代回收后大小)(143360K(年轻代总大小))] [ParOldGen: 13088K(老年代回收前大小)->13236K(老年代回收后大小)(55808K(老年代总大小))] 19218K(整个堆回收前大小)->13236K(整个堆回收后大小)(199168K(堆总大小)), [Metaspace: 20856K(持久代回收前大小)->20856K(持久代回收后大小)(1069056K(持久代总大小))], 0.1216713(耗时) secs] [Times: user=0.44(用户耗时) sys=0.02(系统耗时), real=0.12(实际耗时) secs]

查看Class字节码

通过 java 自带命令可以查看:

1
javap -c Test

查看16进制字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ xxd Test.class 
00000000: cafe babe 0000 0034 0023 0a00 0600 1509 .......4.#......
00000010: 0016 0017 0800 180a 0019 001a 0700 1b07 ................
00000020: 001c 0100 063c 696e 6974 3e01 0003 2829 .....<init>...()
00000030: 5601 0004 436f 6465 0100 0f4c 696e 654e V...Code...LineN
00000040: 756d 6265 7254 6162 6c65 0100 124c 6f63 umberTable...Loc
00000050: 616c 5661 7269 6162 6c65 5461 626c 6501 alVariableTable.
00000060: 0004 7468 6973 0100 134c 636f 6d2f 6d64 ..this...Lcom/md
00000070: 642f 7379 6e63 2f54 6573 743b 0100 046d d/sync/Test;...m
00000080: 6169 6e01 0016 285b 4c6a 6176 612f 6c61 ain...([Ljava/la
00000090: 6e67 2f53 7472 696e 673b 2956 0100 0461 ng/String;)V...a
000000a0: 7267 7301 0013 5b4c 6a61 7661 2f6c 616e rgs...[Ljava/lan
000000b0: 672f 5374 7269 6e67 3b01 0010 4d65 7468 g/String;...Meth
000000c0: 6f64 5061 7261 6d65 7465 7273 0100 0a53 odParameters...S
000000d0: 6f75 7263 6546 696c 6501 0009 5465 7374 ourceFile...Test
000000e0: 2e6a 6176 610c 0007 0008 0700 1d0c 001e .java...........
000000f0: 001f 0100 0ce6 8891 e5a4 aae9 9abe e4ba ................

留意开头的魔数, 这是 Java 之父詹姆斯·高斯林(James Gosling)留下的彩蛋.

常用的JVM参数

来自 无敌码农

java启动参数共分为三类:

其一是标准参数( -),所有的JVM实现都必须实现这些参数的功能,而且向后兼容

其二是非标准参数( -X),默认jvm实现这些参数的功能,但是并不保证所有jvm实现都满足,且不保证向后兼容

其三是非Stable参数( -XX),此类参数各个jvm实现会有所不同,将来可能会随时取消,需要慎重使用

正确打印日志:

1
2
3
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/home/GCEASY/gc-%t.log

打印ClassLoader日志(这个参数会在控制台打印所有类加载/卸载信息):

1
2
-XX:+TraceClassLoading 
-XX:+TraceClassUnloading

OOM时Dump内存:

1
2
-XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=/crashes/my-heap-dump.hprof

OOM时执行脚本(比如重启):

1
-XX:OnOutOfMemoryError=/scripts/restart-myapp.sh

最后

附上两张来自 无敌码农 的图片:

参考并转载于:
http://blog.csdn.net/seu_calvin/article/details/51404589
http://blog.csdn.net/seu_calvin/article/details/51892567
http://blog.csdn.net/seu_calvin/article/details/52301541
http://www.importnew.com/23774.html
http://www.importnew.com/23774.html

更多JVM汇总请看:

Jvm系列文章

关于Jvm知识看这一篇就够了

---------------- The End ----------------
ookamiAntD wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
谢谢大爷~

Author:ookamiAntD Yang
Link:http://yangbingdong.com/2017/jvm-knowledge-collect-01/
Contact:yangbingdong1994@gmail.com
本文基于 知识共享署名-相同方式共享 4.0 国际许可协议发布
转载请注明出处,谢谢!

分享到: