JVM快速入门
JVM探究
- 请你谈谈你对JVM的理解? java8虚拟机和之前的变化更新?
- 什么是OOM,什么是栈溢出StackOverFlowError? 怎么分析?
- JVM的常用调优参数有哪些?
- 内存快照如何抓取,怎么分析Dump文件?知道吗?
- 谈谈JVM中,类加载器你的认识?
1、JVM的位置
JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

2、JVM的体系结构
JVM 内存区域主要分为线程私有区域【程序计数器、虚拟机栈、本地方法区】、线程共享区 域【JAVA 堆、方法区】、直接内存。
线程私有数据区域生命周期与线程相同, 依赖用户线程的启动/结束 而 创建/销毁(在 Hotspot VM 内。
线程共享区域随虚拟机的启动/关闭而创建/销毁。
直接内存并不是 JVM 运行时数据区的一部分, 但也会被频繁的使用: 在 JDK 1.4 引入的 NIO 提 供了基于 Channel 与 Buffer 的 IO 方式, 它可以使用 Native 函数库直接分配堆外内存, 然后使用 DirectByteBuffer 对象作为这块内存的引用进行操作, 这样就避免了在 Java 堆和 Native 堆中来回复制数据, 因此在一些场景中可以显著提高性能。


程序计数器(线程私有)
一块较小的内存空间, 是当前线程所执行的字节码的行号指示器,每条线程都要有一个独立的 程序计数器,这类内存也称为“线程私有”的内存。 正在执行 java 方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址)。如 果还是 Native 方法,则为空。 这个内存区域是唯一一个在虚拟机中没有规定任何 OutOfMemoryError 情况的区域。
虚拟机栈(线程私有)
是描述java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame) 用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
每一个方法从调用直至执行完成 的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
栈帧( Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接 (Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception)。栈帧随着方法调用而创 13/04/2018 Page 23 of 283 建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异 常)都算作方法结束。

本地方法区(线程私有)
本地方法区和 Java Stack 作用类似, 区别是虚拟机栈为执行 Java 方法服务, 而本地方法栈则为 Native 方法服务, 如果一个 VM 实现使用 C-linkage 模型来支持 Native 调用, 那么该栈将会是一个 C 栈,但 HotSpot VM 直接就把本地方法栈和虚拟机栈合二为一。
堆(Heap-线程共享)-运行时数据区
是被线程共享的一块内存区域,创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行 垃圾收集的最重要的内存区域。由于现代 VM 采用分代收集算法, 因此 Java 堆从 GC 的角度还可以 细分为: 新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年代。
方法区/永久代(线程共享)
即我们常说的永久代(Permanent Generation), 用于存储被 JVM 加载的类信息、常量、静 态变量、即时编译器编译后的代码等数据. HotSpot VM把GC分代收集扩展至方法区, 即使用Java 堆的永久代来实现方法区, 这样 HotSpot 的垃圾收集器就可以像管理 Java 堆一样管理这部分内存, 而不必为方法区开发专门的内存管理器(永久带的内存回收的主要目标是针对常量池的回收和类型 的卸载, 因此收益一般很小)。
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版 本、字段、方法、接口等描述等信息外,还有一项信息是常量池 13/04/2018 Page 24 of 283 (Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加 载后存放到方法区的运行时常量池中。 Java 虚拟机对 Class 文件的每一部分(自然也包括常量 池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会 被虚拟机认可、装载和执行。
3、类加载器
我们在IDE中编写的Java源代码被编译器编译成.class的字节码文件。然后由我们得ClassLoader负责将这些class问价加载到JVM中去执行。
JVM中提供了三层的ClassLoader:
- Bootstrap classLoader:主要负责加载核心的类库(java.lang.*等),构造ExtClassLoader和APPClassLoader。
- ExtClassLoader:主要负责加载jre/lib/ext目录下的一些扩展的jar。
- AppClassLoader:主要负责加载应用程序的主函数类

获取类加载器
@Test
public void test5(){
//实例化对象
Person person = new Person();
//获取这个对象的类
Class<? extends Person> aClass = person.getClass();
System.out.println(aClass);
//获取这个类的加载器
ClassLoader classLoader = aClass.getClassLoader();
System.out.println(classLoader); //AppClassLoader
//这个类加载器的父加载器
System.out.println(classLoader.getParent()); //ExtClassLoader
//这个类加载器的爷爷加载器 最上面的加载器底层是用C写的,Java获取不到
System.out.println(classLoader.getParent().getParent()); //null : 1.不存在 2.java程序获取不到
}
4、双亲委派机制
JDK1.8,在rt.jar->java->lang路径下可以找到ClassLoader类,找到LoadClass方法
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
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.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
可以通过一张图来描述上面代码的执行

从上图中我们就更容易理解了,当一个*.class这样的文件要被加载时。不考虑我们自定义类加载器,首先会在AppClassLoader中检查是否加载过,如果有那就无需再加载了。如果没有,那么会拿到父加载器,然后调用父加载器的loadClass方法。父类中同理会先检查自己是否已经加载过,如果没有再往上。注意这个过程,直到到达Bootstrap classLoader之前,都是没有哪个加载器自己选择加载的。如果父加载器无法加载,会下沉到子加载器去加载,一直到最底层,如果没有任何加载器能加载,就会抛出ClassNotFoundException。
为什么要设计这种机制
这种设计有个好处是,如果有人想替换系统级别的类:String.java。篡改它的实现,但是在这种机制下这些系统的类已经被Bootstrap classLoader加载过了,所以并不会再去加载,从一定程度上防止了危险代码的植入。
5、沙箱安全机制
我们都知道,程序员编写一个Java程序,默认的情况下可以访问该机器的任意资源,比如读取,删除一些文件或者网络操作等。当你把程序部署到正式的服务器上,系统管理员要为服务器的安全承担责任,那么他可能不敢确定你的程序会不会访问不该访问的资源,为了消除潜在的安全隐患,他可能有两种办法:
- 让你的程序在一个限定权限的帐号下运行。
- 利用Java的沙箱机制来限定你的程序不能为非作歹。
什么是沙箱?
Java安全模型的核心就是Java沙箱(sandbox),什么是沙箱?沙箱是一个限制程序运行的环境。沙箱机制就是将 Java 代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。沙箱主要限制系统资源访问,那系统资源包括什么?——CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。
所有的Java程序运行都可以指定沙箱,可以定制安全策略。
java中的安全模型:
在Java中将执行程序分成本地代码和远程代码两种,本地代码默认视为可信任的,而远程代码则被看作是不受信的。对于授信的本地代码,可以访问一切本地资源。而对于非授信的远程代码在早期的Java实现中,安全依赖于沙箱 (Sandbox) 机制。如下图所示 JDK1.0安全模型

但如此严格的安全机制也给程序的功能扩展带来障碍,比如当用户希望远程代码访问本地系统的文件时候,就无法实现。因此在后续的 Java1.1 版本中,针对安全机制做了改进,增加了安全策略,允许用户指定代码对本地资源的访问权限。如下图所示 JDK1.1安全模型

在 Java1.2 版本中,再次改进了安全机制,增加了代码签名。不论本地代码或是远程代码,都会按照用户的安全策略设定,由类加载器加载到虚拟机中权限不同的运行空间,来实现差异化的代码执行权限控制。如下图所示 JDK1.2安全模型

当前最新的安全机制实现,则引入了域 (Domain) 的概念。虚拟机会把所有代码加载到不同的系统域和应用域,系统域部分专门负责与关键资源进行交互,而各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问。虚拟机中不同的受保护域 (Protected Domain),对应不一样的权限 (Permission)。存在于不同域中的类文件就具有了当前域的全部权限,如下图所示 最新的安全模型(jdk 1.6)

以上提到的都是基本的Java 安全模型概念,在应用开发中还有一些关于安全的复杂用法,其中最常用到的 API 就是 doPrivileged。doPrivileged 方法能够使一段受信任代码获得更大的权限,甚至比调用它的应用程序还要多,可做到临时访问更多的资源。有时候这是非常必要的,可以应付一些特殊的应用场景。例如,应用程序可能无法直接访问某些系统资源,但这样的应用程序必须得到这些资源才能够完成功能。
组成沙箱的基本组件:
-
字节码校验器(bytecode verifier):确保Java类文件遵循Java语言规范。这样可以帮助Java程序实现内存保护。但并不是所有的类文件都会经过字节码校验,比如核心类。 -
类装载器(class loader):其中类装载器在3个方面对Java沙箱起作用
- 它防止恶意代码去干涉善意的代码;
- 它守护了被信任的类库边界;
- 它将代码归入保护域,确定了代码可以进行哪些操作。
虚拟机为不同的类加载器载入的类提供不同的命名空间,命名空间由一系列唯一的名称组成,每一个被装载的类将有一个名字,这个命名空间是由Java虚拟机为每一个类装载器维护的,它们互相之间甚至不可见。
类装载器采用的机制是双亲委派模式。
- 从最内层JVM自带类加载器开始加载,外层恶意同名类得不到加载从而无法使用;
- 由于严格通过包来区分了访问域,外层恶意的类通过内置代码也无法获得权限访问到内层类,破坏代码就自然无法生效。
存取控制器(access controller):存取控制器可以控制核心API对操作系统的存取权限,而这个控制的策略设定,可以由用户指定。安全管理器(security manager):是核心API和操作系统之间的主要接口。实现权限控制,比存取控制器优先级高。安全软件包(security package):java.security下的类和扩展包下的类,允许用户为自己的应用增加新的安全特性,包括:- 安全提供者
- 消息摘要
- 数字签名
- 加密
- 鉴别
6、Native
凡是带了native关键字的,说明Java的作用范围达不到,去调用底层C语言的库。
JNI:Java Native Interface(Java本地方法接口)
凡是带了native关键字的方法就会进入本地方法栈,其他的就是Java栈。
Native Interface本地接口
本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序,Java在诞生的时候是C/C++横
行的时候,想要立足,必须有调用C,C++的程序,于是就在内存中专门开辟了一块区域处理标记为native的代
码,它的具体做法是在Native Method Stack中登记native方法,在( Execution Engine )执行引擎执行的时候加
载Native Libraies.
目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产
设备,在企业级应用中已经比较少见。因为现在的异构领域间通信很发达,比如可以使用Socket通信,也可以使
用Web Service等等。
Native Method Stack
它的具体做法是Native Method Stack中登记native方法,在( Execution Engine )执行引擎执行的时候加载
Native Libraies。[本地库]
7、PC寄存器
程序计数器: Program Counter Register
每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向像
一条指令的地址,也即将要执行的指令代码) ,在执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以
忽略不计
8、方法区
Method Area方法区(static final, Class, 常量池)
方法区是被所有线程共享,所有字段和方法字节码,以及一些特殊方法,如构造函数,接口代码也在此定义,
简单说,所有定义的方法的信息都保存在该区域,此区域属于共享区间;
静态变量、常量、类信息(构造方法、接口定义)、运行时的常量池存在方法区中,但是实例变量存在堆内存
中,和方法区无关
9、栈
一种数据结构,类似于弹夹,先进后出,后进先出,经常跟栈一起提起的还有一个队列,它是先进先出(FIFO:first input first output)
程序=数据结构+算法
为什么main()方法先执行,最后结束?
栈:栈内存,主管程序的运行,生命周期和线程同步,线程结束,栈内存也就释放,对于栈来说,不存在垃圾回收问题。
栈里面储存:8大基本类型+对象引用+实例的方法
栈运行原理:栈帧

栈满了就会报错:StackOverflowError
栈+堆+方法区的交互关系

10、三种JVM
- Sun公司
HotSpot Java Hotspot(TM) 64-Bit Server VM (build 25. 181-b13,mixed mode) - BEA
JRockit - IBM
J9 VM
我们学习的是:HotSpot
11、堆
Heap,一个JVM只有一个堆内存,堆内存的大小是可以调节的。
类加载器读取了类文件后,一般会把什么东西放到堆中?
类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行。
堆内存还要细分为三个区域:
- 新生区(伊甸园区)
- 老年区
- 永久区

GC垃圾回收主要是在新生区和老年区。
假设内存满了,OOM,堆内存不够!java.lang.OutOfMemoryerror. Java heap space
在JDK8以后,永久存储区改了个名字(元空间)
新生代
是用来存放新生的对象。一般占据堆的 1/3 空间。由于频繁创建对象,所以新生代会频繁触发 MinorGC 进行垃圾回收。新生代又分为 Eden 区、ServivorFrom、ServivorTo 三个区。
- 类:诞生和成长的地方,甚至死亡。
- 伊甸园,所有的对象都是在伊甸园区new出来的!
- 幸存者区(from,to)
老年代
主要存放应用程序中生命周期长的内存对象。
老年代的对象比较稳定,所以 MajorGC 不会频繁执行。在进行 MajorGC 前一般都先进行 了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足 够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间。
老年代的对象比较稳定,所以 MajorGC 不会频繁执行。在进行 MajorGC 前一般都先进行 了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足 够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间。
真理:经过研究, 99%的对象都是临时对象!
永久代
这个区域常驻内存的。用来存放JDK自身携带的Class对象。Interface元数据, 存储的是Java运行时的一些环境或类信息,这个区域不存在垃圾回收!关闭VM虚拟就会释放这个区域的内存。
一个启动类,加载了大量的第三方jar包。Tomcat部署了太多的应用,大量动态生成的反射类,不断的被加载。直到内存满,就会出现OOM;
- jdk1.6之前: 永久代。常量池是在方法区:
- jdk1.7: 永久代。但是慢慢的退化了,去永久代,常量池在堆中
- jdk1.8之后:无永久代,常量池在元空间
java8中,取消永久代,方法存放于元空间(Metaspace),元空间仍然与堆不相连,但与堆共享物理内存,逻辑上可认为在堆中

元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用 本地内存。
类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中
12、堆内存调优
模拟一次OOM(OutOfMemoryError)
public static void main(String[] args) {
String str = "床前明月光";
while (true) {
str = str + str;
}
}
理论上字符串的长度可以一直增长,但是计算机的内存跟不上,所以堆中的内存会溢出
调用Java 中的方法查看JVM的内存
public static void main(String[] args) {
//返回虚拟机试图使用的最大内存
long maxMemory = Runtime.getRuntime().maxMemory();//字节
//返回JVM的初始化总内存
long totalMemory = Runtime.getRuntime().totalMemory();
System.out.println("max="+maxMemory+"字节\t"+(maxMemory>>20)+"M");
System.out.println("total="+totalMemory+"字节\t"+(totalMemory>>20)+"M");
}
输出
max=2841116672字节 2709M
total=192937984字节 184M
我的电脑内存是12G
默认情况下:JVM分配的总内存是电脑内存的1/4,而初始化的内存为电脑内存的1/64
当遇到OOM问题时:
1、尝试扩大堆内存看结果

idea中在VM options:添加以下代码可以设置JVM的初始内存,打印GC详细信息
-Xms1024m -Xmx1024m -XX:+PrintGCDetails
-Xms #设置初始化内存分配大小1/64
-Xmx #设置最大分配内存,默认1/4
-XX: +PrintGCDetails #打印GC详情
此时上面代码的输出为:
max=1029177344字节 981M
total=1029177344字节 981M
Heap
PSYoungGen total 305664K, used 20971K [0x00000000eab00000, 0x0000000100000000, 0x0000000100000000)
eden space 262144K, 8% used [0x00000000eab00000,0x00000000ebf7afb8,0x00000000fab00000)
from space 43520K, 0% used [0x00000000fd580000,0x00000000fd580000,0x0000000100000000)
to space 43520K, 0% used [0x00000000fab00000,0x00000000fab00000,0x00000000fd580000)
ParOldGen total 699392K, used 0K [0x00000000c0000000, 0x00000000eab00000, 0x00000000eab00000)
object space 699392K, 0% used [0x00000000c0000000,0x00000000c0000000,0x00000000eab00000)
Metaspace used 3283K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 359K, capacity 388K, committed 512K, reserved 1048576K
可以看到系统给JVM分配的内存为981M,新生代(SYoungGen)占用305664K,老生代(ParOldGen)占用699392K,通过计算(305664k+699392k)=1005056k=981.5M,加起来正好等于系统给堆分配的内存空间,故印证了元空间并不在虚拟机中,而是使用本地内存(逻辑上存在,物理上不存在)。
2、分析内存(专业工具)
内存快照分析工具:MAT(eclipse)、Jprofiler(idea)
使用JProfiler工具分析OOM原因
1、使用idea安装Jprofiler插件
打开plugin界面搜索Jprofiler,安装 重启idea
2、安装Jprofiler客户端
百度Jprofiler安装包进行安装
3、在idea中选择Jprofiler客户端的安装路径

4、制造一个OOM错误
public class Demo01 {
byte[] bytes = new byte[1024*1024];//1M
public static void main(String[] args) {
List<Demo01> list= new ArrayList();
int count = 0;
try {
while (true) {
list.add(new Demo01());//问题代码
count++;
}
} catch (Error e) {
System.out.println("count="+count);
e.printStackTrace();
}
}
}
输出
count=2524
java.lang.OutOfMemoryError: Java heap space
at com.zt.Demo01.<init>(Demo01.java:8)
at com.zt.Demo01.main(Demo01.java:16)
5、打印Dump
设置VM options

-Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
设置初始化内存小一点,方便测试
如果遇到其他异常可以使用-XX:+HeapDumpOn+异常名称
此时再运行控制台输出
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid11964.hprof ...
Heap dump file created [7748498 bytes in 0.072 secs]
count=6
java.lang.OutOfMemoryError: Java heap space
at com.zt.Demo01.<init>(Demo01.java:8)
at com.zt.Demo01.main(Demo01.java:16)
提示已经生成dump文件,打开项目的根目录可以找到

通过Jprofiler客户端打开可以查看OOM的问题所在

还可以通过程序运行的线程来找出问题代码的具体位置

13、GC
JVM在进行GC时,并不是对这三个区域统- -回收。大部分时候,回收都是新生代
GC两种类:轻GC (普通的GC), 重GC (全局GC)

常用算法
复制算法(copying)
为了解决 Mark-Sweep 算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小 的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用 的内存清掉。



这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原 本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。
- 好处:没有内存的碎片
- 坏处:浪费了内存空间,多了一半空间永远是空to。 假设对象100%存活(极端情况)
复制算法最佳使用场景:对象存活度较低的时候;新生区
标记清除算法(Mark-Sweep)
最基础的垃圾回收算法,分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清 除阶段回收被标记的对象所占用的空间。

从图中我们就可以发现,该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可 利用空间的问题。
- 优点:不需要额外的空间!
- 缺点:两次扫描,严重浪费时间,会产生内存碎片.
标记整理算法(Mark-Compact)
结合了以上两个算法,为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同,标记后不是清 理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。

总结
内存效率:复制算法>标记清除算法>标记压缩算法(时间复杂度)
内存整齐度:复制算法=标记压缩算法>标记清除算法
内存利用率:标记压缩算法=标记清除算法>复制算法
思考-个问题:难道没有最优算法吗?
答案:没有,没有最好的算法,只有最合适的算法
GC :分代收集算法
年轻代:
- 存活率低
- 复制算法!
老年代:
- 区域大:存活率
- 标记清除(内存碎片不是太多) +标记压缩混合实现
参考:

浙公网安备 33010602011771号