如果有兴趣了解更多相关内容,欢迎来我的个人网站看看:耶瞳空间
JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,具体实现有很多,以下内容如果不额外声明,默认是HotSpot JVM。JVM它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java虚拟机包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法域。JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。简单来说JVM就是用来解析和运行Java程序的。
其中JVM的内存结构十分重要,内存结构中的各个区的详细情况可参照下图(JDK8及以后的版本),下文也会对各个结构做详细说明。
JVM的好处:
程序计数器的英文全称是Program Counter Register,又叫程序计数寄存器。Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。我们的程序计数器是Java对物理硬件的屏蔽和抽象,他在物理上是通过寄存器来实现的。寄存器可以说是整个CPU组件里读取速度最快的一个单元,因为读取/写指令地址这个动作是非常频繁的。所以Java虚拟机在设计的时候就把CPU中的寄存器当做了程序计数器,用他来存储地址,将来去读取这个地址。
每个线程都有自己的程序计数器,这样当线程执行切换的时候就可以在上次执行的基础上继续执行。仅仅从一条线程线性执行的角度而言,它的作用可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
特点:
java虚拟机栈(Java Virtual Machine Stacks)是每个线程运行时所需的内存。每个栈由多个栈帧(Stack Frame)组成,每个方法执行都会创建一个栈帧,对应着该方法调用时所占用的内存,栈帧包含局部变量表、操作数栈、动态连接、方法出口等,下面会详细讲。每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。
每一个方法的开始执行到执行完成,都对应着一个栈帧在虚拟机中从入栈到出栈的过程。栈顶的栈帧就是当前执行方法的栈帧,称为当前栈帧(Current Stack Frame),这个栈帧关联的方法被称为当前方法(Current Method)。当这个方法调用其他方法的时候就会创建一个新的栈帧,这个新的栈帧会被放到虚拟机栈的栈顶,变为当前的活动栈,只有这时该栈帧的局部变量才能被使用。当这个栈帧所有指令都完成的时候,这个栈帧就会被移除,之前的栈帧变为活动栈,前面移除栈帧的返回值变为这个栈帧的一个操作数。
栈帧包含局部变量表、操作数栈、动态连接、方法出口等数据:
栈顶缓存技术(Top Of Stack Cashing):基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器(寄存器的主要优点:指令更少,执行速度快)中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
对于栈的线程安全问题,有以下情况需要说明:
/**
* @author eyesYeager
* @date 2023/2/4 19:37
*/
public class Demo {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10; i++) {
new Thread(() -> m(sb)).start();
}
}
// 存在线程安全问题,因为sb是对象,存储在堆中,可以被其他线程访问
public static void m(StringBuilder sb) {
sb.append(1);
System.out.print(""); // 为了让append执行间距拉大,使结果更明显
sb.append(2);
sb.append(3);
sb.append(" ");
System.out.println(sb);
}
}
然后还有这种情况,下面在这里插入代码片
这个函数即使是多线程调用,每个线程拿到的StringBuilder都会是"123",但是它并不是线程安全的,需要说明的是,线程安全问题并不只是说多个线程调用这个方法不会出问题。一个程序在多线程环境下执行可能出现错误,那这个程序就是线程不安全。如果下面的方法在某程序中,它的方法返回的值可以在其他方法内被多线程访问,就认为它是线程不安全的。
public static StringBuilder m() {
StringBuilder sb = new StringBuilder();
sb.append(1);
System.out.print("");
sb.append(2);
sb.append(3);
sb.append(" ");
return sb;
}
JVM的内存溢出有以下几种情况:
这里主要谈栈溢出(StackOverflowError),《Java 虚拟机规范》中文版描述如下:如果线程请求的栈容量超过栈允许的最大容量的话,Java虚拟机将抛出一个StackOverflow异常;如果Java虚拟机栈可以动态扩展,并且扩展的动作已经尝试过,但是无法申请到足够的内存去完成扩展,或者在新建立线程的时候没有足够的内存去创建对应的虚拟机栈,那么Java虚拟机将抛出一个OutOfMemory 异常。
出现栈溢出的情况,一般情况下是程序错误所致的,比如死递归,代码如下:
/**
* @author eyesYeager
* @date 2023/2/4 21:16
*/
public class Demo {
private static int count = 0;
public static void m() {
count++;
m();
}
public static void main(String[] args) {
try {
m();
} catch (Throwable e) {
System.out.println(count);
e.printStackTrace();
}
}
}
我们在运行程序的时候,可以通过设置-Xss来修改栈的最大容量(idea各版本界面不一样,总之在VM options框里填):
对于线程运行诊断,有以下两个案例:
1. CPU占用高
基本步骤为:
top
定位是哪个进程对CPU的占用过高ps H -eo pid,tid,%cpu | grep 进程id
(用ps命令进一步定位是哪个线程引起的CPU占用过高)jstack 进程id
(jstack是JVM自带的Java堆栈跟踪工具,用于打印出给定的java进程ID、core file、远程调试服务的Java堆栈信息)演示如下:
jstack获取到的线程id是十六进制的,而ps命令获取到的是十进制的,因此需要先做个转换,再去jstack找对应线程。
2. 迟迟得不到结果
这里当然不包括网络不好之类的情况,一般出现该情况都是因为出现线程死锁。使用jstack 进程id
查看进程信息:
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务,即非java方法,一般是使用C/C++语言进行实现。并且jvm规范中对本地方法栈中方法使用的语言、方式、数据结构并没有任何强制规定,所以具体虚拟机可以根据需求自由的去实现它。甚至HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一。
java虚拟机调用本地方法时,需要给这个本地方法提供的内存空间。本地方法翻译过来Native Method,在这里是指不是由java代码编写的方法,因为java代码有一定的限制,java有时候不能和操作系统底层交互,所以就需要使用C或者C++ 等一些别的语言和操作系统进行交互,然后由java代码调用这些封装好的本地方法接口间接和操作系统进行交互。最典型的使用地方就是所有的Object类,Object中clone方法声明就是native,会发现在java源码中所有的native方法是没有方法实现的,它所有的实现都是由其它语言进行编写的比如C语言,java通过clone方法接口去进行调用C和C++的方法实现。
其特点如下:
《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。但实际上并不能说所有,只能说大部分的对象实例以及数组。因为JVM为了提高性能,引入了逃逸分析技术,逃逸分析技术会判断方法中new的对象是否发生逃逸,如果没有发生逃逸,就可以对该对象进行栈上分配,有关逃逸分析后面会详细描述。
一个JVM实例只有一个堆内存,堆也是Java内存管理的核心区域,堆在JVM启动的时候创建,其空间大小也被创建,物理上不需要连续但逻辑上连续,是JVM中最大的一块内存空间。所有线程共享Java堆,在方法结束后,堆中的对象不会马上删除,仅仅在垃圾收集的时候被删除,堆是GC(垃圾收集器)执行垃圾回收的重点区域。不马上删除也是为了效率最大化,因为GC会影响用户线程,频繁GC会导致程序性能下降,所以应该尽量减少GC频率。
我们可以看堆内存是如何划分的,先声明一下:
Java 7及之前堆内存逻辑上分为三部分:新生代+老年代+永久代。但在Java 8版本废弃了永久代,替代的是元空间(MetaSpace),元空间与永久代类似,都是方法区的实现。方法区会在后面详细介绍。再次声明,本文没有额外声明说的都是HotSpot JVM,《Java虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。而永久代就是HotSpot JVM在Java 8之前对方法区的实现,其他版本的JVM并没有永久代。
需要注意的是,对于永久代和元空间在不在堆上,网上各个资料都不太统一或者说比较模糊。我查了一下Java虚拟机规范手册,可以看到,方法区在逻辑上是属于堆的。但是物理上在不在没有说明,因此得看各种虚拟机的具体实现。可以肯定的是,对于HotSpot JVM来说,永久代物理上是在堆中的,但元空间是不在的,元空间物理上是在本地内存中。
对象分配过程:
-XX:MaxTenuringThreshold=
设置,默认是15次。老年代存储长期存活的对象,占满时会触发Major GC=Full GC,GC期间会停止所有线程等待GC完成,所以对响应要求高的应用尽量减少发生Major GC,避免响应超时。为什么分代:将对象根据存活概率进行分类,对存活时间长的对象,放到固定区,从而减少扫描垃圾时间及GC频率。针对分类进行不同的垃圾回收算法,对算法扬长避短。争取最大的效率。
为什么survivor分为两块相等大小的幸存空间:主要为了解决碎片化。如果内存碎片化严重,也就是两个对象占用不连续的内存,已有的连续内存不够新对象存放,就会触发GC。
JVM堆内存常用参数:
参数 | 描述 |
---|---|
-Xms | 堆内存初始大小,单位m、g |
-Xmx(MaxHeapSize) | 堆内存最大允许大小,一般不要大于物理内存的80% |
-XX:PermSize | 非堆内存初始大小,一般应用设置初始化200m,最大1024m就够了 |
-XX:MaxPermSize | 非堆内存最大允许大小 |
-XX:NewSize(-Xns) | 年轻代内存初始大小 |
-XX:MaxNewSize(-Xmn) | 年轻代内存最大允许大小,也可以缩写 |
-XX:SurvivorRatio=8 | 年轻代中Eden区与Survivor区的容量比例值,默认为8,即8:1 |
-Xss | 堆栈内存大小 |
在生产环境中,往往需要根据硬件情况调整堆内存初始大小(-Xms)与堆内存最大允许大小(-Xmx),一般推荐将两者调整的一样大(尚硅谷宋红康说的,不是我说的),因为堆的扩容与回收,是需要GC的,频繁GC会影响程序性能。
TLAB(thread Local Allocation Buffer),即线程本地分配缓存区。TLAB在Eden区,是JVM为每个线程分配的一个私有缓存区域。多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略。
使用以下程序进行演示(此时-Xmx为1m):
/**
* @author eyesYeager
* @date 2023/2/4 21:16
*/
public class Demo {
public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String a = "hello";
while (true) {
i++;
list.add(a);
a += a;
}
} catch (Throwable e) {
System.out.println(i);
e.printStackTrace();
}
}
}
调整-Xmx为100m,再次运行:
堆内存诊断的一些工具:
这里就不详细介绍了,有兴趣可以参考下列博客:
逃逸分析(Escape Analysis),是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,JVM能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
如果没有逃逸分析,那么对象只能分配到堆中,当我们在一个方法体内new一个对象,并且该对象在方法执行过程中未发生逃逸,那么按照JVM的机制,首先会在堆内存创建类的实例,然后将此对象的引用压入调用栈,继续执行。但如果采用逃逸分析对JVM进行优化。即针对栈的重新分配方式,首先找出未逃逸的变量,将该变量直接存到栈里,无需进入堆,分配完成后,继续调用栈内执行,最后线程执行结束,栈空间被回收,局部变量也被回收了。这种操作方式减少了堆中对象的分配和销毁,从而优化性能。
逃逸方式分为两种:
逃逸分析的好处:如果一个对象不会在方法体内,或线程内发生逃逸(或者说是通过逃逸分析后,使其未能发生逃逸)
方法区用于存储已被已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码等等。示意图如下,下面的图片显示的是JVM加载类的时候,方法区存储的信息:
方法区(Method Area)的基本理解:
java.lang.OutOfMemoryError:PermGen space
或者java.lang.OutOfMemoryError:Metaspace
《Java 虚拟机规范》中明确说明:“尽管方法区在逻辑上是堆的一部分,但如果只是想简单地实现它,可以选择不进行垃圾回收或对其进行压缩。”可以看出虚拟机规范只规定逻辑上方法区是堆的一部分,并未约束物理层面的实现,对于HotSpot JVM而言,在Java8以前方法区被称为永久代,使用的就是堆内存
但是从Java8开始,HotSpot JVM为了融合JRockit VM而废除了永久代,改为元空间,此时方法区使用的是本地内存,而非堆内存。其大小可以通过-XX:MetaspaceSize=N
和-XX:MaxMetaspaceSize=N
设置,MaxMetaspaceSize默认是-1,此时元空间最大内存为本地内存。如果超出限制,也会报OOM。
栈、堆、方法区的交互关系:
在上图中我们可以看到,Class文件中除了有类的版本、字段、方法、接口等描述信息外, 还有一项信息是常量池,用于存放编译器生成的各种静态常量(或者叫做字面常量/字面量)和符号引用,这部分内容被类加载后进入方法区的运行时常量池中存放。 运行时常量池相对于Class文件常量池的另外一个特征具有动态性,可以在运行期间将新的常量放入池中(典型的如String类的intern方法)。
运行时常量池和class文件的常量池是一一对应的,它就是class文件的常量池来构建的。运行时常量池中有两种类型,分别是symbolic references符号引用和static constants静态常量,其中静态常量不需要后续解析,而符号引用需要进一步进行解析处理。
int a=1
这里的a为左值,1为右值,在这个例子中1就是静态常量int a = 1
的例子中,a是字段名称,所以是符号引用。
最开始这些静态常量和符号引用都是保存在常量池中,他们都是静态信息。当程序运行时被加载到内存后,这些符号才有对应的内存地址信息。这些常量一旦被转入内存就会变成运行时常量池。运行时常量池在方法区中。
再说的明白一些,到底什么是常量池,什么是运行时常量池?Math类,生成的对应的class文件,class文件中定义了一个常量池集合,这个集合用来存储一系列的常量。这时候的常量池是静态常量池。
当程序运行起来,会将类信息加载到方法区中,并为这些常量分配内存地址,这时原来的静态常量池就转变成了运行时常量池。符号引用在程序运行以后被加载到内存中,原来的代码就会被分配内存地址,引用这个对象的地方就会变成直接引用,也就是我们说的动态链接了。
可以用做个演示,代码如下:
public class Demo {
public static void main(String[] args) {
System.out.println("hello world");
}
}
我们让它生成字节码,并用jdk自带的工具javap将字节码反编译,可以看到下面分别是类的基本信息、常量池与类方法定义:
F:\language\java\code\code\base>javap -v target/classes/Demo.class
Classfile /F:/language/java/code/code/base/target/classes/Demo.class
Last modified 2023-2-11; size 515 bytes
MD5 checksum 31f60b2a177f239676128ac620cd0d71
Compiled from "Demo.java"
public class Demo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#20 // java/lang/Object."":()V
#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #23 // hello world
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #26 // Demo
#6 = Class #27 // java/lang/Object
#7 = Utf8
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 LDemo;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 Demo.java
#20 = NameAndType #7:#8 // "":()V
#21 = Class #28 // java/lang/System
#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#23 = Utf8 hello world
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
#26 = Utf8 Demo
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (Ljava/lang/String;)V
{
public Demo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LDemo;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello world
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 8: 0
line 9: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
SourceFile: "Demo.java"
以3: ldc #3
为例,ldc表示加载参数,那怎么知道它加载哪个参数呢?可以看到后面有个#3
,这就是指向常量池的一个参数,我们回到常量池进行查表,可以看到:
以上演示都是常量池,然后回到运行时常量池,常量池是class文件中的,当该类被加载,他的常量池信息就会放到运行时常量池,并把里面的符号地址变为真实地址。
StringTable是JVM里的一个重要的部分。在了解前可以先看看下面这段代码的运行结果:
public class Demo {
public static void main(String[] args) {
String a = "a";
String b = "b";
String s0 = "ab";
String s1 = "a" + "b";
String s2 = a + b;
String s3 = new String("a") + new String("b");
String s4 = s0.intern();
System.out.println(s0 == s1); // true
System.out.println(s0 == s2); // false
System.out.println(s0 == s3); // false
System.out.println(s0 == s4); // true
}
}
对于不了解JVM的人来说,这个结果应该是有些出乎意料的,我们可以先去了解StringTable,再来看这个运行结果。
StringTable在jdk1.7以前是存在于方法区的运行时常量池中,但在1.7之后,改为了存在于堆中。String table中存储的并不是String类型的对象,存储的而是指向String对象的索引,真实对象还是存储在堆中。详细的可以参考这个:看了这篇文章,我搞懂了StringTable
String具有不可变性,当我们如下定义一个字符串时,“hello”就被存放在StringTable中,而变量s是一个引用,s指向了StringTable中的“hello”:
String s = "hello"
当我们把s的值改一下,改成"hello world",这时候,并不是原先s指向的”hello“的值改变为了”hello world“,而是指向了一个新的字符串。
String s = "hello";
s = "hello world";
如何去验证是指向了一个新的字符串而不是修改其内容呢,我们可以打印一下hash值看看:
再来看字符串的拼接:
String s0 = "ab";
String s1 = "a" + "b";
使用javap反编译字节码如下,可以看到s0和s1是加载的同一个地址,这其实是javac在编译期的优化,因为"a" + "b"只能是"ab"
。由于s0和s1指向相同,因此两者也相等。
然后看这个例子:
String a = "a";
String b = "b";
String s0 = "ab";
String s2 = a + b;
可以看到,a + b
底层执行的操作就是:new StringBuilder().append("a").append("b").toString()
查看StringBuilder的toString源码,可见,此处创建了一个新的String对象,因此s0不等于s2
new String("a") + new String("b")
同理,所以s0不等于s3,字节码反编译如下:
再看String s4 = s0.intern();
,查看intern()源码,注释中说明intern方法的作用就是尝试将一个字符串放入StringTable中,如果不存在就放入StringTable并返回StringTable中的地址,如果存在的话就直接返回StringTable中的地址。这是jdk1.8版本中intern方法的作用,jdk1.6版本中有些不同,1.6中intern尝试将字符串对象放入StringTable,如果有则并不会放入,如果没有会把此对象复制一份,放入StringTable, 再把StringTable中的对象返回。不过我们在这里不讨论1.6版本。因此s0.intern()
返回的就是s0本身,所以s0等于s4。
/**
* Returns a canonical representation for the string object.
*
* A pool of strings, initially empty, is maintained privately by the
* class {@code String}.
*
* When the intern method is invoked, if the pool already contains a
* string equal to this {@code String} object as determined by
* the {@link #equals(Object)} method, then the string from the pool is
* returned. Otherwise, this {@code String} object is added to the
* pool and a reference to this {@code String} object is returned.
*
* It follows that for any two strings {@code s} and {@code t},
* {@code s.intern() == t.intern()} is {@code true}
* if and only if {@code s.equals(t)} is {@code true}.
*
* All literal strings and string-valued constant expressions are
* interned. String literals are defined in section 3.10.5 of the
* The Java™ Language Specification.
*
* @return a string that has the same contents as this string, but is
* guaranteed to be from a pool of unique strings.
*/
public native String intern();