初识JVM

0 阅读13分钟

引言

jvm 是java可以实现跨平台部署的关键,即一次编译处处执行。jvm 是在操作系统和java程序之间的一层内存管理机制,使java程序员能够不关心操作内存,使编程更加高效。

jvm,jre,jdk 的关系

jvm:jvm 是编译环境。

jre:在jvm 中添加了基础类库。

jDK:它是面向 Java 开发者的完整工具包。JDK = JRE + 开发工具(如编译器 javac、调试器 jdb、打包工具 jar 等)。

jvm 指令执行流程

首先编译成.class 文件,然后拿到指令交给解释器,然后解释器转化为机器码,交给cpu执行。

对于热点代码,JIT 编译器会将其整体编译成机器码,并缓存起来。下次再执行这段代码时,JVM 就不再经过解释器了,而是直接调用已经编译好的机器码,这大大提高了运行效率。

java8 和1.8 是啥关系

  1. Java 8:这是产品名称(Product Name)。
    从 Java 9 开始,Oracle 改变了命名规则,不再使用 "1.x" 的前缀,直接叫 Java 9、Java 10、Java 11 等。但为了照顾历史习惯,Java 8 这个叫法被保留并广泛使用。
  2. JDK 1.8:这是内部版本号(Internal Version Number)。
    在 Java 9 之前,Java 的内部版本号一直是 1.x 的格式(比如 Java 7 是 1.7,Java 6 是 1.6)。虽然对外宣传叫 Java 8,但它的内部版本号依然延续了传统,叫 1.8.0_xxx

jvm基本结构

JVM的位置

三种JVM:

JVM 只是一套规范,不同的公司有不同的实现,以下是几种常见的JVM。

  • Sun公司:HotSpot 用的最多
  • BEA:JRockit
  • IBM:J9VM

我们学习都是:HotSpot

JVM的体系结构

  • jvm调优:99%都是在方法区和堆,大部分时间调堆。 JNI(java native interface)本地方法接口。

程序计数器

概述

先编译成.class 文件,然后拿到指令交给解释器,然后解释器转化为机器码,交给cpu执行。这就是指令执行的过程。

程序计算器就是记录下一条执行指令的地址。硬件上是通过寄存器实现的,就是cpu的取址执行流程上的取下一条指令。

特点

  • 线程私有的,每一个线程有一个程序计数器。

栈与队列

  • 在计算机流传有一句废话: 程序 = 算法 + 数据结构
  • 但是对于大部分同学都是: 程序 = 框架 + 业务逻辑
  • 栈:后进先出 / 先进后出
  • 队列:先进先出(FIFO : First Input First Output)

栈管理程序运行

  • 存储一些基本类型的值、对象的引用、方法等。
  • 栈的优势是,存取速度比堆要快,仅次于寄存器,栈数据可以共享。

思考:为什么main方法最后执行!为什么一个test() 方法执行完了,才会继续走main方法!

喝多了吐就是栈,吃多了拉就是队列

栈的特点

栈里面会放什么东西那?

  • 8大基本类型 + 对象的引用 + 实例的方法。栈里面的东西也叫栈帧。换个说法就是每个方法执行时,所占用的内存。

栈运行原理

  • 栈帧是一种用于帮助虚拟机执行方法调用与方法执行的数据结构。他是线程私有的,**一个线程拥有一个独立的虚拟机栈,而这个栈中包含了多个栈帧。栈帧是线程私有的,不存在跨线程共享栈帧的情况。**栈帧中包含了封装了方法的局部变量表、动态链接信息、方法的返回地址以及操作数栈等信息。
  • 第一个方法从调用开始到执行完成,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
  • 栈是先进后出的,先被加入的方法后执行。

当一个方法A被调用时就产生了一个栈帧F1,并被压入到栈中,A方法又调用了B方法,于是产生了栈帧F2也被压入栈中,B方法又调用了C方法,于是产生栈帧F3也被压入栈中 执行完毕后,先弹出F3, 然后弹出F2,在弹出F1........

  • 遵循 “先进后出” / "后进先出" 的原则。

  • 栈满了,抛出异常:stackOverflowError

  • 对象实例化的过程。

栈涉及到的问题

栈内存的设置

可以通过设置 -Xxs 来设置栈内存。

栈内存不要设置过大的栈内存,栈内存越大,线程占用的内存越大,但是机器的内存大小是一定的,所以栈内存越大线程数越少。栈内存越大只是能提高方法中可以调用更多的方法。

方法内的局部变量是否是线程安全的?
  • 如果没有逃离方法的作用访问,是线程安全的。
  • 如果是局部变量引用了对象,并逃离了方法的作用,需要考虑线程安全。

这里解释了为啥引用了对象会有线程安全问题,而基本类型不会?基本类型只存在栈帧中,方法结束,变量就销毁了,不会被其他变量访问到。虽然 引用本身是线程安全的(存在当前线程的栈帧里),但它指向的对象实例存放在堆(Heap)中。堆是线程共享的。

对象逃逸:

  1. 作为返回值return obj; (调用方可能把它赋值给一个成员变量,或者传给其他线程)。
  2. 作为参数传递someOtherMethod(obj); (如果其他方法把它存到了全局的静态集合中)。
  3. 赋值给共享的成员变量this.globalObj = obj; (成员变量在堆中,所有线程可见)。
栈内存溢出

栈内存溢出报错信息:

原因如下:

  • **栈帧过多:**方法递归调用没有正确的出口,导致栈溢出
  • 栈帧过大

线程诊断

常用的线程诊断工具:jstack 是 JDK 自带的命令行工具,能直接生成线程快照并自动检测死锁。

cpu占用高

线程死锁

本地方法栈

Java调用C++ 程序所调用的方法,放在本地方法栈中。java 中用native 修饰的方法都是本地方法。

Native

  • 编写一个多线程类启动。
public static void main(String[] args) { 
            new Thread(()->{ },"your thread name").start(); 
 }
  • 点进去看start方法的源码:
public synchronized void start() {
      
        if (threadStatus != 0)
            throw new IllegalThreadStateException();

        group.add(this);

        boolean started = false;
        try {
            start0();   // 调用了一个start0方法
            started = true;
        } finally {
            try {
                if (!started) {
                    group.threadStartFailed(this);
                }
            } catch (Throwable ignore) {
              
            }
        }
    }
    // 这个Thread是一个类,这个方法定义在这里是不是很诡异!看这个关键字native;
    private native void start0();
  • 凡是带了native关键字的,说明 java的作用范围达不到,去调用底层C语言的库!
  • JNI:Java Native Interface(Java本地方法接口)
  • 凡是带了native关键字的方法就会进入本地方法栈;
  • Native Method Stack 本地方法栈
  • 本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序,Java在诞生的时候是C/C++横行的时候,想要立足,必须有调用C、C++的程序,于是就在内存中专门开辟了一块区域处理标记为native的代码,它的具体做法是 在 Native Method Stack 中登记native方法,在 ( ExecutionEngine ) 执行引擎执行的时候加载Native Libraies。
  • 目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间通信很发达,比如可以使用Socket通信,也可以使用Web Service等等,不多做介绍!

堆的结构

Java7之前

  • Heap 堆,一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的。

  • 类加载器读取了类文件后,需要把类,方法,常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行。

  • 堆内存分为三部分:

    • 新生区 Young Generation Space Young/New
    • 养老区 Tenure generation space Old/Tenure
    • 永久区 Permanent Space Perm
  • 堆内存逻辑上分为三部分:新生,养老,永久(元空间 : JDK8 以后名称)。

  • 是线程共享的。

Java8以及之后

java8 把永久区变成元空间,不在占用堆的空间,而是占用操作系统内存。

谁空谁是to

  • GC垃圾回收主要是在新生区和养老区,又分为轻GC 和 重GC,如果内存不够,或者存在死循环,就会导致

  • 在JDK8以后,永久存储区改了个名字(元空间)。

主要存放内容

堆是 JVM 中最大的一块内存区域,几乎所有的对象实例和数组都在这里分配内存。

  • 通过new 关键字创建的实例对象
  • 对象内的成员变量:对象内部的非静态字段(引用类型或基本类型)都存放在该对象实例的内存块中。
  • 数组

堆_内存诊断工具

jps常见的命令

  • jps -lv 可以同时查看完整的类名/路径以及 JVM 启动参数,信息最全面且实用

jmap常见命令

  • jmap 主要用于生成 Java 虚拟机进程的内存映射信息、查看堆内存使用情况或导出堆转储快照
  • **-heap**:显示 Java 堆的详细信息,包括使用的垃圾回收器、堆配置(最大/最小堆大小)、以及各代(Eden、Survivor、Old Gen 等)的内存使用情况。
  • **-histo**:显示堆中对象的直方图,按占用内存大小排序,列出类名、实例数量和占用字节数,常用于快速定位内存占用大户。
  • **-histo:live**:仅显示存活对象的直方图。注意:此操作会触发一次 Full GC。

jconsole

可视化页面

在jdk\bin中有对应的入口 jconsole.exe

方法区

方法区结构

jdk1.6

jdk1.8

主要存放内容

方法区是被所有线程共享,所有字段和方法字节码,以及一些特殊方法,如构造函数,接口代码也在此定义,简单说,所有定义的方法的信息都保存在该区域,此区域属于共享区间;

方法区用于存储已被虚拟机加载的类信息、常量、静态变量等。它相当于类的“蓝图”或“模板”。而堆存放的是对象实例。

主要存放以下内容:

  • 静态变量、常量、类信息(构造方法、接口定义)、运行时的常量池存在方法区中,但是实例变量存在堆内存中(也就是new 出来的对象),和方法区无关。
  • static ,final ,Class ,常量池~
  • 方法区在1.8之前是在堆中的,在1.8之后再元空间中,不占用堆内存,而是占用操作系统内存。
  • 静态变量 (Static Variables):类级别的变量。
  • JIT 编译后的代码:即时编译器优化后的本地机器码。

常量池

它类似于一张表,JVM 将各种常量信息以表的形式记录在其中。字节码指令在执行时,会根据这张表里的索引来查找对应的类名、方法名、参数类型等信息。存在于.class 文件中

**核心作用:**作为数据的载体,将大量在代码中被引用的结构信息(如类、方法、字符串等)集中存储,避免将这些庞大的数据直接嵌入到字节码指令中,从而减小了 .class 文件的体积。

运行时常量池

JVM 加载 .class 文件时,会将 Class 文件常量池中的内容加载到内存中,这就形成了运行时常量池。它里面只存储指向字符串对象的引用,而不是字符串对象本身。

存储位置

它是 JVM **方法区(Method Area)**的一部分。在JDK 8以前是堆中的永久代。在 JDK 8 及以后的版本中,方法区由“元空间(Metaspace)”实现,使用的是操作系统的本地内存。

StringTable

位置

StringTable 是存放字符串常量的地方。位置见 jdk1.6 方法区结构 和 jdk1.8 方法区结构。

概述

它是一个全局唯一的哈希表,专门用来存储实际的字符串对象引用。字符串真正的内容是存在堆中的。使用StringTable 是为了节省空间,无论程序中有多少个"hello"这样的字符串,只有一个引用。

但是如果通过动态拼接生成了新的字符串,JVM 会实时检查 StringTable,如果没有,就会把新对象的引用动态添加进去。

请看一下代码:

String a="a";
String b="b";
String ab=a+b;
String stringab="ab";
System.out.println(ab==stringab); // false 

原因:这个新生成的 "ab" 对象存在于堆内存(Heap)中,而不是字符串常量池中。因此,变量 ab 指向的是堆中的一个新对象。

想要把 ab 对象放入StringTable 中有两个方法:

方法一:使用 **final** 关键字触发编译期优化

final String a = "a";
final String b = "b";
String ab = a + b;       // 编译期优化,等价于 String ab = "ab";
String stringab = "ab";
System.out.println(ab == stringab); // 输出 true

方法二:使用 **intern()** 方法手动入池

String a = "a";
String b = "b";
String ab = (a + b).intern(); // 将堆中的 "ab" 放入常量池,并返回池中的引用
String stringab = "ab";
System.out.println(ab == stringab); // 输出 true
关于new String("ab")

请看以下代码:

String s1 = new String("ab");
String s2 = s1.intern();
String s3 = "ab";
System.out.println(s1 == s2); 
System.out.println(s2 == s3); 

这道题的正确答案是:
第一个输出 **false**,第二个输出 **true**

这行代码(String s1 = new String("ab");)发生了啥?

  1. 遇到字面量 "ab",JVM 会先检查字符串常量池(StringTable)。如果池中没有 "ab",就会在池中创建一个 "ab" 对象。
  2. 遇到 new 关键字,JVM 会强制在**堆内存(Heap)**中创建一个全新的 String 对象,其内容也是 "ab"
  3. 变量 s1 指向的是堆内存中的那个新对象。

总结:new String("ab"),其实是两步一个是在堆中创建对象,另一步是去StingTable创建“ab”对象,为其创建缓存。

这行代码(String s2 = s1.intern();)的作用

  1. JVM 会拿着 s1 的内容 "ab" 去字符串常量池中查找。
  2. 因为在第一步中,常量池里已经存在 "ab" 这个对象了。
  3. 所以,intern() 方法会直接返回常量池中那个已有对象的引用。
  4. 变量 s2 指向的是字符串常量池中的 "ab" 对象。

这行代码的作用(String s3 = "ab";)

  1. JVM 去字符串常量池中查找 "ab"
  2. 发现池中已经有了(String s1 = new String("ab")这行代码创建的),于是直接把池中对象的引用赋给 s3。如果没有就在常量池中创建。
  3. 变量 s3 指向的也是字符串常量池中的 "ab" 对象。

总结

这就体现了StringTale 最大的价值,在于处理现实开发中的海量数据。想象一下这个场景:
你从数据库或文件中读取了 100 万条用户记录,每条记录里都有一个表示性别的字段 "男""女"

  • 如果没有 StringTable:JVM 会在堆内存中创建 100 万个 "男" 对象和 100 万个 "女" 对象。这会瞬间撑爆内存,导致频繁的 GC 甚至 OOM(内存溢出)。
  • 有了 StringTable:无论读取多少万条数据,JVM 在 StringTable 中永远只保留一个 **"男"** 对象和一个 **"女"** 对象。那 100 万个变量,仅仅存储了指向这两个对象的引用(在 64 位 JVM 中,一个引用通常只占 4 到 8 个字节)。