Java Virtual Machine - java 程序的运行环境(java 二进制字节码的运行环境)
好处:
比较:
jvm jre jdk
Program Counter Register 程序计数器(寄存器)
作用,是记住下一条jvm指令的执行地址
特点
0: getstatic #20 // PrintStream out = System.out;
3: astore_1 // --
4: aload_1 // out.println(1);
5: iconst_1 // --
6: invokevirtual #26 // --
9: aload_1 // out.println(2);
10: iconst_2 // --
11: invokevirtual #26 // --
14: aload_1 // out.println(3);
15: iconst_3 // --
16: invokevirtual #26 // --
19: aload_1 // out.println(4);
20: iconst_4 // --
21: invokevirtual #26 // --
24: aload_1 // out.println(5);
25: iconst_5 // --
26: invokevirtual #26 // --
29: return
小结:
当前线程所执行的字节码的行号指示器
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
,它是程序控制流的指示器,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成。
大白话: 程序计数器负责记录当前执行到哪一行字节码指令了;
字节码解释器就是从程序计数器拿到当前执行的字节码指令进行解释,然后翻译为机器码交给cpu执行
并且同时程序计数器会获取下一条字节码指令的行号
Java Virtual Machine Stacks (Java 虚拟机栈)
代码演示
/**
* @author 大忽悠
* @create 2022/1/8 15:20
*/
@Slf4j
public class Main {
public static void main(String[] args)
{
mehtod1();
}
public static void mehtod1()
{
int a=1,b=2;
method2(a,b);
}
private static int method2(int a, int b) {
int c=a+b;
return c;
}
}
栈内存就是一次次的方法调用产生的栈帧内存,而栈帧内存在每一次方法调用后,都会被弹出栈,即自动被回收掉,不需要垃圾回收来管理栈内存
栈内存可以在运行时,用过一个
虚拟机参数-Xss来指定大小
栈内存越大,线程数越少; 如果内存有500m,我们设置每个线程的栈内存为2m,那么只能同时最多运行250个线程; 如果设置为1m,那么可以同时最多运行500个线程; 由此可知,栈内存设置的越大,反而会影响运行的效率; 栈内存越大,只能够提高方法更多次的递归调用
如果方法内局部变量没有逃离方法的作用访问,它是线程安全的
如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
局部变量只要没有逃离方法的作用范围,便是线程安全的:
/**
* @author 大忽悠
* @create 2022/1/8 15:20
*/
@Slf4j
public class Main {
public static void main(String[] args)
{
for(int i=0;i<3;i++)
{
new Thread(()->{
mehtod1();
}).start();
}
}
public static void mehtod1()
{
int i=0;
for (int j=0;j<10;j++)
{
i++;
}
log.debug("i= {}",i);
}
}
输出
[Thread-0] [DEBUG] [2022年01月09日 10时47分08秒639毫秒] 消息:i= 10
[Thread-1] [DEBUG] [2022年01月09日 10时47分08秒639毫秒] 消息:i= 10
[Thread-2] [DEBUG] [2022年01月09日 10时47分08秒639毫秒] 消息:i= 10
/**
* @author 大忽悠
* @create 2022/1/8 15:20
*/
@Slf4j
public class Main {
public static void main(String[] args)
{
new Thread(()->{
while(true){}
},"大忽悠线程1号").start();
}
}
定位
top命令提供了实时的对系统处理器的状态监视.它将显示系统中CPU最“敏感”的任务列表.该命令可以按CPU使用.内存使用和执行时间对任务进行排序
ps 为我们提供了进程的一次性的查看,它所提供的查看结果并不动态连续的;如果想对进程时间监控,应该用 top 工具 H是列出当前进程下所有线程信息,-eo是选择自己感兴趣的属性进行展示
可以根据线程id 找到有问题的线程,进一步定位到问题代码的源码行号
/**
* @author 大忽悠
* @create 2022/1/8 15:20
*/
@Slf4j
public class Main
{
private static Object a=new Object();
private static Object b=new Object();
public static void main(String[] args)
{
new Thread(()->{
synchronized (a)
{
try {
Thread.sleep(Long.parseLong("1000"));
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b)
{
System.out.println("鸡汤来喽12...");
}
}
},"大忽悠线程1号").start();
new Thread(()->{
synchronized (b)
{
try {
Thread.sleep(Long.parseLong("1000"));
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (a)
{
System.out.println("鸡汤来喽2...");
}
}
},"大忽悠线程2号").start();
}
}
本地方法就是java调用非java代码的接口,并不是所有的 JVM都支持本地方法, 因为 Java虚拟机规范上, 并没有明确要求本地方法的使用语言和具体实现方法. Hotspot VM是本地方法栈和虚拟机栈合二为一的虚拟机
java中本地方法的体现: native标识
为什么要使用Native Method
有时java应用需要与java外面的环境交互。这是本地方法存在的主要原因,你可以想想java需要与一些底层系统如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解java应用之外的繁琐的细节。
JVM支持着java语言本身和运行时库,它是java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎 样,它毕竟不是一个完整的系统,它经常依赖于一些底层(underneath在下面的)系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用C写的,还有,如果我们要使用一些java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。
我们知道,当一个类第一次被使用到时,这个类的字节码会被加载到内存,并且只会回载一次。在这个被加载的字节码的入口维持着一个该类所有方法描述符的list,这些方法描述符包含这样一些信息:方法代码存于何处,它有哪些参数,方法的描述符(public之类)等等。
如果一个方法描述符内有native,这个描述符块将有一个指向该方法的实现的指针。这些实现在一些DLL文件内,但是它们会被操作系统加载到java程序的地址空间。当一个带有本地方法的类被加载时,其相关的DLL并未被加载,因此指向方法实现的指针并不会被设置。当本地方法被调用之前,这些DLL才会被加载,这是通过调用java.system.loadLibrary()实现的
Heap 堆
特点
对象可以当做垃圾被回收的条件是没有人在使用该对象,如果我不断的产生对象,并且这些对象有人在使用他们,这些对象就会占用堆内存而不释放
堆内存溢出演示:
int i=0;
try
{
List<String> list=new ArrayList<>();
String a="hello";
while(true)
{
list.add(a);
a=a+a;
i++;
}
}catch (Throwable e)
{
e.printStackTrace();
System.out.println(i);
}
注意:Stirng对象是不可变对象,因此每次string操作后得到的是一个新的对象
查看当前系统中有哪些 java 进程
查看堆内存占用情况 jmap - heap 进程id; 只能查询某一时刻堆内存占用情况,不能对堆内存做连续监测
Windows 操作系统系统 IDEA 中 jmap 命令 Error
使用演示:
/**
*
* 展示堆内存
*/
public class Main
{
public static void main(String[] args) throws InterruptedException {
System.out.println("1.....");
Thread.sleep(30*1000);
//10 MB
byte[] array=new byte[1024*1024*10];
System.out.println("2....");
Thread.sleep(30*1000);
//让堆内存可以被垃圾回收
array=null;
//手动调用,进行垃圾回收
System.gc();
System.out.println("3....");
Thread.sleep(1000000L);
}
}
图形界面的,多功能的监测工具,可以连续监测
垃圾回收后,内存占用仍然很高。
执行GC之后,堆内存只释放了30M左右。
输入jvirsualvm打开JVM可视化工具,然后复制当前堆内存的快照信息,进行分析排查
这样对堆快照的分析,就可以看出问题所在
jdk工具之JvisualVM、JvisualVM之一–(visualVM介绍及性能分析示例)
下面是Method Area的中文翻译:
Java 虚拟机有一个在所有 Java 虚拟机线程之间共享的方法区。方法区类似于传统语言的编译代码的存储区,或者类似于操作系统进程中的“文本”段。它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化和接口初始化中使用的特殊方法
(第 2.9 节)。
方法区是在虚拟机启动时创建的
。尽管方法区在逻辑上是堆的一部分
,但简单的实现可能会选择不进行垃圾收集或压缩它。本规范不要求方法区域的位置
或用于管理已编译代码的策略。方法区域可以是固定大小,也可以根据计算需要扩大,如果不需要更大的方法区域,可以缩小。方法区的内存不需要是连续的。
Java 虚拟机实现可以为程序员或用户提供对方法区域初始大小的控制,以及在方法区域大小可变的情况下,对最大和最小方法区域大小的控制。
以下异常情况与方法区相关:
如果方法区域中的内存无法满足分配请求,Java 虚拟机将抛出 OutOfMemoryError。
方法区是一个概念,不同的jvm对其的实现不同
Oracle JDK 8之前方法区的实现与JDK8之后
演示:
1.8 之后会导致元空间内存溢出
元空间默认没有内存上限设置,因此最好手动设置上限,才能观察到内存溢出的情况 -XX: MaxMetaspaceSize=8m
/**
* 演示元空间内存溢出
* -XX: MaxMetaspaceSize=8m
*/
public class Main extends ClassLoader//可以用来加载类的二进制字节码
{
public static void main(String[] args){
int j=0;
try{
Main main=new Main();
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[] code = cw.toByteArray();
//执行类的加载
main.defineClass("Class"+i,code,0,code.length);
}
}finally {
System.out.println(j);
}
}
}
* 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
* -XX:MaxMetaspaceSize=8m
1.8 以前会导致永久代内存溢出
* 演示永久代内存溢出 java.lang.OutOfMemoryError: PermGen space
* -XX:MaxPermSize=8m
常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
实例演示
//下面的程序想要运行,首先要编译成二进制字节码
//二进制字节码包含以下几个部分:
//类的基本信息,常量池,类方法定义包含了虚拟机指令
public class Main
{
public static void main(String[] args){
System.out.println("Hello World");
}
}
javap -v 编译后的字节码文件: 该指令可以反编译二进制字节码文件,输出二进制字节码包含的信息
类方法定义:
{
public com.dhy.Main(); //默认提供的构造方法
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 4: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/dhy/Main;
public static void main(java.lang.String[]); //main方法
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code: //下面就是虚拟机指令
stack=2, locals=1, args_size=1
//获取一个静态变量,去常量池中寻找#2对应的值
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
//加载常量池中的值,去常量池找#3对应的值
3: ldc #3 // String Hello World
//执行一次虚方法调用,同理
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 7: 0
line 8: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
SourceFile: "Main.java"
上面的类方法定义部分与还没讲的常量池部分有什么联系呢?
常量池:
Constant pool:
#1 = Methodref #6.#20 // java/lang/Object."<init>":()V
//#2是成员变量 具体在#21和#22
#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 // com/dhy/Main
#6 = Class #27 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/dhy/Main;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 Main.java
#20 = NameAndType #7:#8 // "<init>":()V
//#21是类,具体类对应#28
#21 = Class #28 // java/lang/System
//#22表示名字和类型, 具体对应#29 后面:#30是类型
#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 com/dhy/Main
#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
运行时常量池,常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址(#2等)变为真实地址
首先分析一下下面程序的字节码文件:
public class Main
{
public static void main(String[] args){
String s1="a";
String s2="b";
String s3="ab";
}
}
常量池和字符串池的关系
如果字符串常量池中已经存在了符合a对应的字符串对象,就直接引用字符串常量池中的,否则会新创建一个值为a的字符串对象在堆上,然后将该对象的引用放入字符串常量池中
public class Main
{
public static void main(String[] args){
String s1="a";
String s2="b";
String s3="ab";
String s4=s1+s2;
}
}
这次我们重点看一下第四行代码对应的字节码
StringBuilder的tostring方法源码:
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
可以看出是新创建了一个String对象
问:s3和s4是否是同一个对象
String s1="a";
String s2="b";
String s3="ab";
String s4=s1+s2;
System.out.println(s3==s4);
输出
false
原因:
String s1="a";
String s2="b";
String s3="ab";
String s4=s1+s2;
String s5="a"+"b";
System.out.println(s3==s5);
请判断输出结果
输出
true
原因
常量做拼接,结果是确定的,因此javac会对其做优化
变量做拼接,运行的时候引用的值可能被修改,结果是不确定的,因此会在运行期间通过stringbuilder动态做拼接
测试可以得出下面的数据:
System.out.println("a"); //字符串个数为2373
System.out.println("b");//字符串个数为2374
System.out.println("a");//字符串个数为2374
System.out.println("b");//字符串个数为2374
System.out.println("x");//字符串个数为2375
解释一下jdk8版本的intern方法: 如果字符串对象的引用在字符串常量池中存在,则再次放入的时候,发现存在相同的字符串对象,不会进入放入操作,而是返回此时字符串常量池中对象的引用
String s1 = "a"; //字符串常量池一开始没有放入字符串常常量池
String s2 = "b"; //同上
String s3 = "a" + "b"; //编译器优化后,同上
String s4 = s1 + s2; //new StringBuilder.append("a").append("b").toString()
String s5 = "ab"; //字符串常常量池中有,拿字符串常常量池中的引用
String s6 = s4.intern(); //尝试将s4在堆上的ab放入字符串常常量池中,但是此时字符串常常量池中存在,直接返回字符串常常量池中的对象引用(jdk 1.8)
// 问
System.out.println(s3 == s4);//false,一个在串池中,一个在堆上
System.out.println(s3 == s5); //true都是串池中的引用
System.out.println(s3 == s6);//true,s6最终得到的是串池的引用,s3是串池引用
//下面首先将使用到的常量c和d放入串池
//但是new String("c")是在堆上的,new String("d")也在堆上
//这里x2是StringBuilder创建出来的
String x2 = new String("c") + new String("d");
String x1 = "cd";//"cd"入池,返回池中引用
System.out.println(x1 == x2);//false
x2.intern();//尝试放入常量池,池已有
// 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢
System.out.println(x1 == x2);//false,因为池中早一步有了
jdk 1.6是存放在永久代中的,jdk1.8是存放在堆中的,原因如下:
如何验证?
我们现在了堆内存最大值为10m,现在我们向字符串常量池中再放入10000个字符串常量,此时堆内存不足,会触发垃圾回收
/**
* <p>
* 演示StringTable垃圾回收
* -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
* 设置堆内存最大值 打印字符串表的统计信息 打印垃圾回收的详细信息
* </p>
*/
public class Main
{
public static void main(String[] args)
{
for(int i=0;i<10000;i++)
{
String.valueOf(i).intern();
}
}
}
注意:垃圾回收只有在内存紧张的时候才会触发
StringTable底层是HashTable的实现方式,是桶加链表的实现方式,即桶的个数越多,产生hash冲突的概率越小,如果桶的个数越小,不仅hash冲突次数增加,每一次放入链表的查询次数也会耗费很多时间
思考:如果同时存在一百万个字符串对象,但是其中大部分字符串对象的值都是重复的,如果将这一百万个字符串对象保存在对象,大约需窈几个G的内存,如果将字符串对象都入池,可以起到去重的效果,这样一来,发现占用内存减少到了几百兆
Direct Memory
java并不具备磁盘读写的能力,如果要进行磁盘的读写操作,需要调用操作系统的方法,也就是我们说的本地方法,此时cpu运行状态会从用户态切换到内核态
切换到内核态后,体现在内存这边,操作系统会将数据从磁盘文件读入系统缓冲区中,系统缓冲区java的代码不能控制,因此需要在java堆内存中分配一块java的缓冲区,也就是我们使用nio时,使用的ByteBuffer,然后再将数据从系统缓冲区读入到java缓冲区,然后java就可以操作ByteBuffer里面的数据
上面的操作中数据复制造成了不必要的浪费,即从系统缓冲区读取数据到java缓冲区的过程
直接内存就是在操作系统这边划分出一块内存,这块内存java代码可以直接访问,即操作系统和java代码可以共享这块内存
这样数据直接读取到直接内存,java也可以直接操作直接内存,这样避免了数据的复制
直接内存不受垃圾回收管理
/**
* <p>
* 演示直接内存溢出
* </p>
*/
public class Main
{
static int _100MB=1024*1024*10;
public static void main(String[] args)
{
List<ByteBuffer> list=new LinkedList<>();
int i=0;
try{
while(true)
{
System.out.println(i++);
list.add(ByteBuffer.allocateDirect(_100MB));
}
}finally {
System.out.println(i);
}
}
}
直接内存的释放底层是通过Unsafe对象控制的
下面分析ByteBuffer分配的直接内存,底层是怎么和Unsafe对象联系起来的
直接内存的释放涉及到了垃圾回收过程中的虚引用机制
Cleaner是虚引用类型,特点是当他所关联的对象被垃圾回收时,
Cleaner会触发虚引用的clean方法,
DirectByteBuffer是java对象,会被gc管理
这里会开辟一个线程来处理,这里clean()方法是通过一个refereneceHandler线程来检测这些虚引用对象,当虚引用对象关联的实际对象被回收后,会调用虚引用对象的clean方法,然后调用任务对象的run方法
之所以不推荐直接显示的进行垃圾回收操作,是因为显示垃圾回收时Full GC,会同时触发新生代和老年代的回收,比较费资源