JVM理解
1、JVM的基本介绍
JVM,即 Java Virtual Machine ,是Java 程序的运行环境(Java 二进制字节码的运行环境)。
JVM的作用:
- 一次编写,到处运行
- 自动内存管理,垃圾回收功能
- 数组下标越界检查
- 多态
1.1、JVM、JRE、JDK三者的比较
JVM、JRE、JDK 的关系如下图所示。
- JDK(Java Development Kit):java开发工具包,在JRE的基础上增加编译工具,如javac
- JRE(Java Runtime Environment):java的运行时环境,在JVM的基础上结合一些基础类库
- JVM:java虚拟机, 可以屏蔽java代码与底层虚拟机之间的关系
1.2、常见的JVM
1.3、JVM的整体架构
2、程序计数器
- 是线程私有的。每个线程都有自己的程序计数器,随着线程创建而创建,随线程销毁而销毁
- 不会存在内存溢出
3、虚拟机栈(线程内存)
3.1、虚拟机栈基本介绍
每个栈由多个栈帧(Frame)组成,对应着该线程内各个方法调用时所占用的内存,即线程内每个方法的调用都会创建一个新的栈帧(Stack Frame)。每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。
- 不会。栈帧内存 在每次方法调用结束后会自动弹出栈(自动回收),不需要回收(垃圾回收回收堆内存中无用对象,不会回收栈内存)
- 不是。因为服务器中物理内存是固定大小的,单个栈内存大了,可创建的线程数就少了。虽然栈内可进行更多次方法调用,但由于线程数减少,所以并不会提高效率。
- 可以通过 -Xss 参数来设置栈内存大小,JDK1.5+ 中默认是 1M,一般来说使用默认值即可
- 如果方法内的局部变量没有逃离方法的作用范围,那么它是线程安全的
- 如果是局部变量引用了对象,并逃离了方法的作用范围,需要考虑线程安全
如下:
package JVM; public class Demo01 { public static void main(String[] args) { StringBuilder sb = new StringBuilder(); sb.append(4); sb.append(5); sb.append(6); } /** * 不会有线程安全问题。因为StringBuilder是线程内局部变量,属于线程私有,其他线程无法访问 */ public static void m1() { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append(1); stringBuilder.append(2); stringBuilder.append(3); System.out.println(stringBuilder.toString()); } /** * 不是线程安全的。StringBuilder作为参数传入,StringBuilder可能被其他线程共享,不是线程安全 */ public static void m2(StringBuilder stringBuilder) { stringBuilder.append(1); stringBuilder.append(2); stringBuilder.append(3); System.out.println(stringBuilder.toString()); } /** * 不是线程安全的。虽然StringBuilder是作为局部变量,但是返回结果为StringBuilder,可能被其他线程修改 */ public static StringBuilder m2() { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append(1); stringBuilder.append(2); stringBuilder.append(3); return stringBuilder; } }
3.1.1、栈帧代码演示
代码如下:
/** * 演示栈帧 */ public class Demo1_1 { public static void main(String[] args) throws InterruptedException { method1(); } private static void method1() { method2(1, 2); } private static int method2(int a, int b) { int c = a + b; return c; } }
开启 debug 模式,执行 main 主方法,当调试执行到 method2 方法时,可以看到创建了三个栈帧。当方法 main、method1、method2 执行结束后,栈帧依次被销毁。
3.2、栈内存溢出(StackOverflowError)
- 栈帧过多导致栈内存溢出。比如递归调用方法未正确结束递归
- 栈帧过大导致栈内存溢出。
如下分别为栈帧过多和栈帧多大的示例图:
代码示例,如下是演示栈帧过多导致栈内存溢出的情况:
package cn.itcast.jvm.t1.stack; /** * 演示栈内存溢出 报错信息:java.lang.StackOverflowError * 可以通过设置 JVM 参数来设置栈内存,如:-Xss256k */ public class Demo1_2 { private static int count; public static void main(String[] args) { try { method1(); } catch (Throwable e) { e.printStackTrace(); System.out.println(count); } } private static void method1() { count++; method1(); } }
执行以上 main 方法,可以看到报错如下:
3.3、线程运行诊断
3.3.1、CPU占用过高
通过跑一段无限循环代码来使系统的 CPU 不断飙升,演示如何通过命令来诊断出导致 CPU 过高的线程。
代码示例:
package cn.itcast.jvm.t1.stack; /** * 演示 cpu 占用过高 */ public class Demo1_16 { public static void main(String[] args) { new Thread(null, () -> { System.out.println("1..."); while(true) { } }, "thread1").start(); new Thread(null, () -> { System.out.println("2..."); try { Thread.sleep(1000000L); } catch (InterruptedException e) { e.printStackTrace(); } }, "thread2").start(); new Thread(null, () -> { System.out.println("3..."); try { Thread.sleep(1000000L); } catch (InterruptedException e) { e.printStackTrace(); } }, "thread3").start(); } }
代码编译后,传入 Linux 系统中,通过 java cn.itcast.jvm.t1.stack.Demo1_16 命令来运行该段程序。
然后通过 TOP 命令可以定位哪个进程对cpu的占用过高,如下:
通过 ps H -eo pid,tid,%cpu | grep 进程id 命令进一步定位是哪个线程引起的cpu占用过高,如下:
(注意,左边是进程id,右边是线程id)
如上找到 CPU 占用过高的线程,并且可以定位到具体的代码类名和行数。
3.3.2、程序阻塞运行很久没有结果
如下,通过一段代码演示程序发生线程死锁。
代码如下:
package cn.itcast.jvm.t1.stack; /** * 演示线程死锁 */ class A{}; class B{}; public class Demo1_3 { static A a = new A(); static B b = new B(); public static void main(String[] args) throws InterruptedException { new Thread(()->{ synchronized (a) { try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (b) { System.out.println("show a and b"); } } }).start(); Thread.sleep(1000); new Thread(()->{ synchronized (b) { synchronized (a) { System.out.println("show a and b 222"); } } }).start(); } }
将该代码放置到 Linux 环境上执行,可以看到很久都没有输出结果。
当我们通过 jstack 命令来查看该进程的线程时,可以发现已经发生了死锁。
4、本地方法栈
在 java 虚拟机调用一些本地方法时需要给本地方法提供的内存空间。
- 本地方法:由于java有限制,不可以直接与操作系统底层交互,所以需要一些用c/c++编写的本地方法与操作系统底层的API交互,java可以间接的通过本地方法来调用底层功能。本地方法是由其它语言编写的,编译成和处理器相关的机器代码。本地方法保存在动态链接库中,即.dll(windows系统)文件中,格式是各个平台专有的。
举例:Object的clone()、hashCode()、notify()、notifyAll()、wait()等,一个Native Method就是一个java调用非java代码的接口。
5、堆内存(Heap,线程共享)
5.1、堆内存的基本介绍(新生代、老年代、永久代)
特点:
- 它是线程共享的,堆中对象都需要考虑线程安全的问题。堆跟根程序计数器和虚拟机栈不同的是,后两者都是线程私有的,而堆是线程同享的
- 有垃圾回收机制。当一个对象不再被使用时,该对象就会被垃圾回收机制回收,即该对象内存会被垃圾回收掉。
堆内存区域介绍:
在jvm的堆内存中有三个区域:
- 年轻代:用于存放新产生的对象。
- 老年代:用于存放被长期引用的对象。
- 持久带(或元空间):用于存放Class,method元信息(1.8之后改为元空间)。
详细介绍如下:
年轻代:年轻代中包含两个区:Eden 和survivor,并且用于存储新产生的对象,其中有两个survivor区。
老年代:年轻代在垃圾回收多次都没有被GC回收的时候就会被放到老年代,以及一些大的对象(比如缓存,这里的缓存是弱引用),这些大对象可以不进入年轻代就直接进入老年代
持久代:持久代用来存储class,method元信息,大小配置和项目规模,类和方法的数量有关。
元空间:JDK1.8之后,取消perm永久代,转而用元空间代替。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于元空间并不在虚拟机中,而是使用本地内存,并且可以动态扩容。
为什么分代?
因为不同对象的生命周期是不一样的。80%-98%的对象都是“朝生夕死”,生命周期很短,大部分新对象都在年轻代,可以很高效地进行回收,不用遍历所有对象。而老年代对象生命周期一般很长,每次可能只回收一小部分内存,回收效率很低。
年轻代和老年代的内存回收算法完全不同,因为年轻代存活的对象很少,标记清楚再压缩的效率很低,所以采用复制算法将存活对象移到survivor区,更高效。而老年代则相反,存活对象的变动很少,所以采用标记清楚压缩算法更合适。
5.2、堆内存溢出
堆内存溢出模拟代码:
package cn.itcast.jvm.t1.heap; import java.util.ArrayList; import java.util.List; /** * 演示堆内存溢出 java.lang.OutOfMemoryError: Java heap space * 可以通过配置JVM参数:-Xmx8m 来设置最大堆内存 */ public class Demo1_5 { public static void main(String[] args) { int i = 0; try { List<String> list = new ArrayList<>(); String a = "hello"; while (true) { list.add(a); // hello, hellohello, hellohellohellohello ... a = a + a; // hellohellohellohello i++; } } catch (Throwable e) { e.printStackTrace(); System.out.println(i); } } }
6、方法区(Method Area)
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。方法区存储类的结构的相关信息,如运行时常量池、成员变量、方法数据、成员方法和构造器的代码等。
方法区在虚拟机启动时创建,其逻辑上是堆的一个组成部分,但在实现时不同的JVM厂商可能会有不同的实现。方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
组成如下:以Oracle的HotSpot为例
- jdk1.6:永久代(PermGen space),占用JVM内存空间
- jdk1.8:元空间(Metaspace),移出JVM内存(除StringTable),放入操作系统内存
6.1、方法区内存溢出
通过不断创建类来演示产生方法区内存溢出,如下:
package cn.itcast.jvm.t1.metaspace; import jdk.internal.org.objectweb.asm.ClassWriter; import jdk.internal.org.objectweb.asm.Opcodes; /** * 元空间内存溢出 java.lang.OutOfMemoryError: Metaspace * 设置元空间大小:-XX:MaxMetaspaceSize=8m * 永久代内存溢出 java.lang.OutOfMemoryError: PermGen space * 设置永久代内存大小:-XX:MaxPermSize=8m */ public class Demo1_8 extends ClassLoader { // 可以用来加载类的二进制字节码 public static void main(String[] args) { int j = 0; try { Demo1_8 test = new Demo1_8(); for (int i = 0; i < 10000; i++, j++) { // ClassWriter 作用是生成类的二进制字节码 ClassWriter cw = new ClassWriter(0); // 版本号, public, 类名, 包名, 父类, 接口 cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null); // 返回 byte[] byte[] code = cw.toByteArray(); // 执行了类的加载 test.defineClass("Class" + i, code, 0, code.length); // Class 对象 } } finally { System.out.println(j); } } }
当使用 jdk1.8 及之后的版本时,内存溢出报错提示:java.lang.OutOfMemoryError: Metaspace。当使用 jdk1.8 之前的版本时,内存溢出报错提示:java.lang.OutOfMemoryError: PermGen space
(默认的元空间内存大小为操作系统的内存大小,可能没那么容易产生内存溢出,可以通过设置 jvm 参数限制元空间内存大小来演示内存溢出现象)