JVM的位置
JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。JVM的位置如下图所示。
JVM运行在操作系统之上,对于不同的操作系统需要运行不同的JVM。因此:Java程序是跨平台的,但JVM不跨平台。

JVM的体系架构图
JVM的体系架构如下图所示。重点关注堆、栈和方法区。
所谓的JVM调优就是在调堆,栈、本地方法栈和程序计时器一定不会产生垃圾。

类加载器ClassLoader
首先来看看一个类加载到JVM的基本结构,如下图所示。

类的加载、连接和初始化(了解)
加载:查找并记载类的二进制数据。
连接分为三个阶段:
- 验证:保证被加载的类的正确性。
- 准备:给类静态变量分配内存空间,赋值一个默认的初始值。
- 解析:把类中的符号引用转换为直接引用。
注:在把java编译为class文件时,虚拟机并不知道所引用的地址,而是使用助记符来进行标记,这就是符号引用,类加载器需要把助记符转换为真正的直接引用,找到对应的直接地址。
初始化:给类的静态变量赋值正确的值。
public class Test{
public static int a = 1;
}
如上述代码所示,类加载的过程大致如下:
1、加载:编译文件为 .class 文件,通过类加载,加载到JVM。
2、连接: (1) 保证Class类文件没有问题;(2) 给int类型分配内存空间,a = 0;(3) 符号引用转换为直接引用。
3、初始化:经过这个阶段的解析,把1赋值给变量 a。
static加载分析
先来看一段代码:
package com.wunian.classloader;
/**
* static加载分析
*
* JVM参数
* -XX:+TraceClassLoading 用于追踪类的加载信息并打印出来
* rt.jar 出厂自带,最高级别的类加载器要加载的
* 分析项目启动为什么这么慢,快速定位自己的类有没有被加载
*/
public class ClassLoaderDemo2 {
public static void main(String[] args) {
System.out.println(MyChild1.str2);
}
}
class MyParent1{
private static String str="Hello World!";
static{
System.out.println("MyParent1 static");
}
}
class MyChild1 extends MyParent1{
public static String str2="Hello World,My Child";
static{
System.out.println("MyChild1 static");
}
}
最终运行结果为:
MyParent1 static
MyChild1 static
Hello World,My Child
可以在启动参数VM options中添加-XX:+TraceClassLoading
参数追踪类的加载信息并打印出来,这将有助于理解类的加载顺序。
由于MyChild1继承了MyParent1,因此在MyChild1调用str2的时候,会进行初始化,初始化之前必须先初始化其父类MyParent1,初始化时首先会加载static代码块,因此我们会看到父类的static代码块中的输出语句先执行,父类初始化完毕后再初始化子类,因此MyChild1类的static代码块接着被加载,最后才加载static成员变量。
因此可以得到结论:
- 初始化一个类时,首先会初始化其父类。
- 类初始化时会先加载static代码块,然后加载static变量。
final加载分析
先来看两段代码:
package com.wunian.classloader;
/**
* final加载分析
*/
public class ClassLoaderDemo3 {
public static void main(String[] args) {
System.out.println(MyParent2.str);
}
}
class MyParent2{
//final常量在编译阶段时候放入常量池
//这里将常量放入了ClassLoaderDemo3的常量池中,之后ClassLoaderDemo3与MyParent2就没关系了
public static final String str="hello world";
static {
System.out.println("MyParent2 static");
}
}
package com.wunian.classloader;
import java.util.UUID;
/**
* final加载分析
*
* 当一个常量的值并非编译期间可以确定的,那么这个值不会放入方法调用类的常量池中
* 程序运行期间的时候,会主动使用常量所在的类
*/
public class ClassLoaderDemo4 {
public static void main(String[] args) {
System.out.println(MyParent4.str);
}
}
class MyParent4{
public static final String str= UUID.randomUUID().toString();
static {
System.out.println("MyParent4 static");
}
}
最终运行结果分别为:
hello world
MyParent4 static
628c844a-e186-4287-8ac8-3809ec660f0b
为什么看起来相似的两段代码其运行结果却完全不同呢?
这是由常量的加载机制决定的:
- 当一个常量的的值在编译期间可以确定,就会在编译阶段放入方法调用类的常量池中,之后方法调用类和常量所在的类就没有关系了。因此第一段代码不会加载MyParent2中的静态代码块,也就不会输出“MyParent2 static”了。
- 反之,当一个常量的值并非编译期间可以确定的,那么这个值不会放入方法调用类的常量池中, 程序运行期间的时候,会主动使用常量所在的类。显然第二段代码中的str的值调用了UUID类的randomUUID方法,一定会去初始化UUID类,因此不能在编译期确定str的值,因此会初始化MyParent4类,导致static代码块会被加载,所以先输出了“MyParent4 static”。
ClassLoader分类
Java虚拟机自带三种加载器:
- BootStrap,根加载器,加载系统的包,如JDK核心库中的类rt.jar包。
- Ext,扩展类加载器,加载一些扩展jar包中的类。
- Sys/App,系统(应用类)加载器,加载开发人员自己编写的类。
只需要继承ClassLoader这个抽象类,就能自定义自己的类加载器,一般很少使用。
双亲委派机制
双亲委派模型是JVM中类的加载机制,这个模型要求除了Bootstrap加载器外,其余的类加载器都要有自己的父加载器。子加载器通过组合来复用父加载器的代码,而不是使用继承。在某个类加载器加载class文件时,它首先委托父加载器去加载这个类,依次传递到顶层类加载器(Bootstrap)。如果顶层加载不了(它的搜索范围中找不到此类),子加载器才会尝试加载这个类。
双亲委派机制可以保护Java的核心类不会被自己定义的类所替代,一层一层的让父类去加载,如果顶层的加载器不能加载,然后再向下类推。
示例代码如下:
package com.wunian.classloader;
public class ClassLoaderDemo5 {
public static void main(String[] args) {
Object o=new Object();//jdk自带的
ClassLoaderDemo5 demo5=new ClassLoaderDemo5();//实例化一个自己定义的对象
//null在这里并不代表没有,只是java触及不到
System.out.println(o.getClass().getClassLoader());//null
System.out.println(demo5.getClass().getClassLoader());//AppClassLoader
System.out.println(demo5.getClass().getClassLoader().getParent());//ExtClassLoader
System.out.println(demo5.getClass().getClassLoader().getParent().getParent());//null
//思考:为什么自己定义的java.lang.String没有生效?
//jvm中有机制可以保护自己的安全
//双亲委派制度: 一层一层的让父类去加载,如果顶层的加载器不能加载,然后再向下类推
// 双亲委派机制 可以保护java的核心类不会被自己定义的类所替代
// AppClassLoader 03
// ExtClassLoader 02
// BootStrap (最顶层) 01 java.lang.String rt.jar
}
}
Native方法
我们都知道,Java是无法开启线程的,必须调用C语言的库来开启线程,因为Java无法操作硬件。
native,只要带了此关键字,说明Java的作用范围达不到,只能去调用底层C语言的库。
Robot类提供了许多鼠标键盘操作的方法,这些方法都使用了native关键字,可以通过这个类实现一个简单的自动化脚本。
为什么会有Native这个东西?
1995年,java 必须可以去调用 c、c++的库,所以说Java就在内存中专门开辟了一块区域标记为 native 方法。
程序计数器
每个线程都有一个程序计数器,是线程私有的。
程序计数器就是一块十分小的内存空间,几乎可以忽略不计。
作用:看做当前字节码执行的行号指示器。
分支、循环、跳转、异常处理都需要依赖于程序计数器来完成。

如上图所示,左侧是代码,右侧是对应的Java字节码,Code列就是程序计时器,冒号右边是底层的程序指令:
bipush
: 将 int、float、String、常量值推送至栈顶。
istore
:将一个数值从操作数栈存储到局部变量表。
iadd
:相加。
imul
:相乘。
方法区渊源
方法区(Method Area )是 Java虚拟机规范中定义的运行是数据区域之一,和堆(heap)一样可以在线程之间共享。
方法区主要用来存储类信息,常量,字符串、静态变量、符号引用、方法代码等。
JDK1.7之前
永久代:用于存储一些虚拟机加载类信息,常量,字符串、静态变量等,这些东西都会放到永久代中。
永久代大小空间是有限的,如果满了会报出异常: OutOfMemoryError:PermGen
JDK1.8之后
彻底将永久代移除HotSpot JVM ,虚拟机加载类信息,常量,字符串、静态变量等放到了堆中或者元空间(Metaspcace)。
元空间就是方法区在HotSpot JVM的实现。
元空间和永久代,都是对JVM规范中方法区的实现。
元空间和永久代最大的区别:元空间并不在Java虚拟机中,使用的是本地内存。
设置元空间大小的JVM参数:-XX:MetaspaceSize10m
栈
栈和队列
栈和队列都是基本的数据结构。
栈:后进先出(LIFO-last in first out),最后插入的元素最先出来。
队列:先进先出(FIFO-first in first out),最先插入的元素最先出来。
它们的结构如下图所示。

程序运行的过程其实就是压栈的过程,栈空了,线程也就结束了。如下图所示。

Stack栈是什么
栈就是管理程序运行的,栈用来存储一些基本类型的值,局部变量,对象的引用,方法等。
栈的优势:存储速度比堆快,仅次于寄存器,栈的数据不可以共享。
当我们使用new关键字创建对象时,实际上是在堆中创建出了一个实例对象,然后在栈中创建该对象的引用,将引用指向堆中的实例对象。如下图所示。

模拟栈溢出异常,代码如下:
package com.wunian.stack;
/**
* StackOverflow 栈溢出
*/
public class StackOverflowDemo {
public static void main(String[] args) {
a();
}
//main a a a a a... 栈满
//java.lang.StackOverflowError
private static void a() {
a();
}
}
因此,栈里面是一定不会存在垃圾回收问题的,一旦线程结束,该栈也就结束了,栈的生命周期和线程是一致的。
栈的原理
Java栈的组成元素是栈帧。
每一次函数的调用,都会在调用栈上维护一个独立的栈帧(Stack Frame)。栈帧的结构如下图所示。

栈、堆、和方法区的交互如下图所示。

注:这里的栈指的是JVM版本为HotSpot中的栈。
常见的几种JVM
- HotSpot(SUN公司)
- JRockit(BEA公司)
- J9VM(IBM公司)
堆
一个JVM实例中只存在一个堆。堆的内存大小是可以调节的。
堆可以存放类、方法、常量和保存了类型引用的真实信息。
堆在逻辑上分为三个部分:
- 新生区(Young),分为Eden区、Survivor区(s0、s1)
- 养老区(Old Tenure)
- 永久区(Perm)
在JDK1.8以后,永久区被元空间替代,在物理上只有新生区和养老区,元空间在本地内存中,不在JVM中。
GC垃圾回收主要是在新生区和养老区。
GC又分为普通GC和Full GC,如果堆满了,就会爆出OutOfMemory异常。
新生区
新生区就是一个类诞生、成长、消亡的地方。
新生区又分为伊甸(Eden)区、幸存者(Survivor)区。幸存者区又分为from区和to区。所有的类在伊甸区被new出来,慢慢的当伊甸区满了,程序还需要创建对象的时候,就会触发一次轻量级GC;清理完一次垃圾之后,会将活下来的对象,会放入幸存者区,清理了 20次之后,出现了一些极其顽强的对象,有些对象突破了15次的垃圾回收。这时候就会将这个对象送入养老区,运行了几个月之后,养老区满了,就会触发一次 Full GC。
假设一个项目运行1年后,整个堆空间彻彻底底的满了,突然有一天系统 爆出OOM异常,这时就需要排除OOM问题或者重启系统。
Sun HotSpot 虚拟机中,堆中的内存管理采用的是分代管理机制,即不同区域使用不同的算法。
在伊甸区99%的对象都是临时对象。
养老区
在新生区经过15次GC都幸存下来的对象进入养老区,养老区满了之后,会触发Full GC。这个15次是默认值,可以修改。
永久区
永久区存放的是一些JDK自身携带的类、接口的元数据。这里的对象几乎不会被垃圾回收。
如果系统爆出OutOfMemoryError:PermGen
,说明永久代不够用了,可能原因是加载了大量的第三方包。
永久代在不同JDK版本中有差异:
JDK1.6之前,有永久代,常量池在方法区。
JDK1.7,有永久代,但是开始尝试去永久代,常量池在堆中。
JDK1.8 之后,没有永久代,取而代之的是元空间;常量池在元空间中。
注:方法区和堆一样,是线程共享区域,是JVM规范中的一个逻辑的部分,它还有一个别名:非堆
初识堆内存调优
Java环境:HotSpot、JDK1.8。
测试一(元空间是否在JVM中)
调优参数:
-XX:+PrintGCDetails
:输出详细的垃圾回收信息。
-Xmx
: 最大分配内存,默认为物理内存的1/4。
-Xms
: 初始分配的内存大小,默认为物理内存的1/64。
测试代码如下:
package com.wunian.oom;
/**
* 默认情况
* maxMemory:1801.0MB 虚拟机试图使用的最大内存量,一般是物理内存的1/4
* totalMemory:123.0MB 虚拟机试图默认的内存总量,一般是物理内存的1/64
*
* 我们可以自定义堆内存的总量
* -XX:+PrintGCDetails 输出详细的垃圾回收信息
* -Xmx:最大分配内存 1/4
* -Xms:初识分配的内存大小 1/64
*
* 调优参数:-Xmx1024m -Xms1024m -XX:+PrintGCDetails
*/
public class HeapParamDemo {
public static void main(String[] args) {
//获取堆内存的初识大小和最大大小
long maxMemory=Runtime.getRuntime().maxMemory();
long totalMemory=Runtime.getRuntime().totalMemory();
System.out.println("maxMemory="+maxMemory+"字节、"+(maxMemory/1024/(double)1024)+"MB");
System.out.println("totalMemory="+totalMemory+"字节、"+(totalMemory/1024/(double)1024)+"MB");
}
}
控制台日志如图所示。

通过计算得知,虚拟机试图使用的最大内存量=新生区的总内存+养老区的总内存,说明元空间不在JVM中。
测试二(模拟OOM)
调优参数为:-Xms8m -Xmx8m -XX:+PrintGCDetails
测试代码如下:
package com.wunian.oom;
import java.util.Random;
/**
*
*
* 调优参数:-Xms8m -Xmx8m -XX:+PrintGCDetails
*
* 分析GC日志:
* [PSYoungGen: 1534K->504K(2048K)] 1534K->656K(7680K), 0.0472431 secs] [Times: user=0.00 sys=0.00, real=0.06 secs]
* 1.GC类型 GC:普通GC FullGC:重GC
* 2.1534K 执行GC之前的大小
* 3.504K 执行GC之后的大小
* 4.(2048K) youngGen的total大小
* 5.0.0472431 secs] 清理的时间
* 6.user 总计GC所占用CPU的时间 sys OS调用等待的时间 real 应用暂停的时间
*
* GC :串行执行 STW(Stop The World) 并行执行 G1
*/
public class OOMDemo {
public static void main(String[] args) {
//java.lang.OutOfMemoryError: Java heap space
String str="OOM Exception";
while (true){
str+=str+new Random().nextInt(999999999)
+new Random().nextInt(999999999);
}
}
}
运行程序,爆出java.lang.OutOfMemoryError: Java heap space
异常,原因是字符串无限进行自增,最后导致新生区满了。
Dump内存快照
在Java程序运行的时候,如果想测试运行的情况怎么办?
我们可以使用一些工具来查看:
- jconsole(JDK自带)
- IDEA的debug模式
- Eclipse的MAT插件
- IDEA的JProfiler插件
JProfiler插件
JProfiler是一款性能瓶颈分析插件。
安装步骤:
1、IDEA安装 JProfiler 插件。Settings
中搜索Plugins
,再搜索JProfiler
,下载安装插件并重启IDEA。
2、windows上安装 JProfiler,注意安装目录路径中不能含有中文和空格。
3、注册码激活。
4、IDEA绑定JProfiler,Settings
中搜索JProfiler
,JProfiler executable
项选择JProfiler安装目录下的bin
目录中的jprofiler.exe
文件。
快速体验JProfiler插件
先来模拟一个OOM异常,调优参数为:-Xmx10m -Xms10m -XX:+HeapDumpOnOutOfMemoryError
,代码如下:
package com.wunian.oom;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* Dump内存快照
*
* jconsole、JProfile测试
*
* -Xmx10m -Xms10m -XX:+HeapDumpOnOutOfMemoryError //当JVM发生OOM时,自动生成DUMP文件
*/
public class DumpDemo {
byte[] bytes=new byte[1*1024*1024];
public static void main(String[] args) throws InterruptedException {
/*System.out.println("start");
TimeUnit.SECONDS.sleep(Integer.MAX_VALUE);*/
//DumpDemo dumpDemo=new DumpDemo();
List<DumpDemo> list=new ArrayList<>();
int count=0;
try {
while(true){
list.add(new DumpDemo());
count++;
}
} catch (Throwable e) {//使用Throwable或Error
System.out.println("count="+count);
e.printStackTrace();
}
}
}
运行程序,我们可以发现在项目目录下自动生成了DUMP文件,我们可以使用JProfiler来打开,然后就可以对DUMP文件进行分析了。或者也直接点击IDEA右上角的JProfiler图标,一段时间后JProfiler会自动打开并监控到当前程序的运行情况。
如果觉得JProfiler操作太复杂,也可以直接去JDK的bin目录下打开jconsole.exe
,连接到当前程序监听端口,也可以对当前程序的运行情况进行监控。
JVM调优参数
输出详细的垃圾回收信息:-XX:+PrintGCDetails
追踪类的加载信息并打印出来:-XX:+TraceClassLoading
设置最大分配内存:-Xmx10m
设置初始分配的内存大小:-Xms10m
发生OOM时,自动生成DUMP文件:-XX:+HeapDumpOnOutOfMemoryError
网友评论