本文介绍: JVM 能识别 class 后缀文件,并且能够解析它的指令,最终调用操作系统上的函数完成指定操作操作系统并不认识这些 class 文件,是 JVM 将它们翻译操作系统识别机器码,最终由操作系统执行这些机器码。Java 具有跨平台、跨语言特性,也是因为 JVM 可以把 Java 字节码、Kotlin 字节码、Groovy 字节码等翻译可以被 Linux、Windows、Mac 系统识别机器码。

1、JVM 基础知识

1.1 JVM 与操作系统关系

JVM 能识别 class 后缀文件,并且能够解析它的指令,最终调用操作系统上的函数完成指定操作操作系统并不认识这些 class 文件,是 JVM 将它们翻译操作系统识别机器码,最终由操作系统执行这些机器码。

JVM与操作系统的关系

Java 具有跨平台、跨语言特性,也是因为 JVM 可以把 Java 字节码、Kotlin 字节码、Groovy 字节码等翻译成可以被 Linux、Windows、Mac 系统识别的机器码。一个 JVM 可以识别不同语言的字节码,但是只能将它们翻译成某一种系统能识别的机器码,不同系统需要使用不同的 JDK,对应的 JVM 只能在该种系统运行

1.2 Java SE 体系架构

体系架构(JVM、JDK、JRE关系)

JVM 与 Java 提供的基础类库(如文件操作网络连接和 IO 等)构成了 JRE,JRE、工具及其 API 和 Java 语言构成了 JDK。JDK 运行在 Linux、Windows平台之上。

注意 JVM 仅仅是一个翻译”,Java 程序想要运行需要基础类库 JRE,如需实现更多额外功能,还需要各种工具(由 JDK 提供,如 javacjavapjavajar 等)。

1.3 JVM 整体

JVM整体

上图给出了 Java 程序执行过程

  1. 经过 javac 编译成class 字节码文件
  2. JVM 中的 ClassLoaderclass 文件加载运行数据区(JVM 管理内存)内的方法区中
  3. 执行引擎执行这些 class 文件,将其翻译操作系统相关函数这里有两种执行方式解释执行(解释一行,执行一行)或 JIT 执行(将热点代码,即执行次数较多的代码或方法直接编译本地代码以提高执行速度

Java 之所以能跨平台,就是由于 JVM 的存在。Java 的字节码,是沟通 Java 语言与 JVM 的桥梁,也是沟通 JVM 与操作系统桥梁

2、 运行数据

Java 引以为豪的就是它的自动内存管理机制。相比于 C++ 的手动内存管理复杂难以理解指针等,Java 程序写起来就方便的多。

在 Java 中,运行数据区(即 JVM 内存)主要分为堆、程序计数器方法区(运行常量池)、虚拟机栈和本地方法栈:

运行时数据区

其中虚拟机栈、本地方法栈和程序计数器线程私有的,而方法区和堆是线程公有的。这 5 个区域是 Java 虚拟机规范定义区域,所有自定义虚拟机都要遵循这个规范

2.1 程序计数器

程序计数器用来记录当前线程执行的字节码的地址分支循环跳转异常线程恢复等都依赖于它。以线程恢复为例,在多线程环境下,当线程数量超过 CPU 核心数时,线程之间会根据时间轮询争夺 CPU 资源,如果一个线程时间片用完了,或者是其他原因导致这个线程的 CPU 资源被提前抢夺,那么这个退出的线程就需要一个单独的程序计数器记录一条运行指令以便一次运行时继续执行剩余任务

比如说使用 javap 命令汇编一个 class 文件得到字节码文件:

字节码

程序计数器记录的是指令地址比如执行完偏移量是 16 的指令 ifne 33 后,时间片用完了,当前线程不能再继续执行了,那么计数器就会记录偏移量 19 的指令 aload_0 的地址,便于下次该线程获得时间片后从正确位置继续执行。

程序计数器是 JVM 内存唯一不会发生 OOM 的区域,因为它只负责保存地址信息,是非常小的一块区域

在执行 native 方法时,程序计数器的值为 undefined

2.2 虚拟机

虚拟机后进先出,保存当前线程中运行的方法所需的数据,栈里的每条数据,就是一个栈帧每个 Java 方法被调用时候,都会创建一个栈帧,并入栈(一个方法对应一个栈帧),方法完成调用出栈。由于虚拟机栈是基于线程的,栈的生命周期和线程是一样的,当所有的栈帧都出栈后,线程也就结束了。

每个栈帧,都包含四个区域,分别是局部变量表、操作数栈、动态连接返回地址。栈的大小默认为 1M(各个平台不同,可参考 -Xsssize),可用参数 –Xss 调整大小,例如 -Xss256k。四个区域的简介如下

虚拟机栈与栈帧

下面用一个简单例子演示栈帧的执行过程

enter description here

将左侧源代码编译class 文件通过 javapc 命令反编译右侧的字节码,注意观察 work() 内的指令。对指令陌生的可以参考 java虚拟机 JVM字节码 指令集 bytecode 操作码 指令分类用法 助记符

指令执行过程及含义,虚拟机栈具体结构下图

指令具体含义及图示

图片右侧work() 执行操作数栈的指令以及指令的含义,图片左侧是局部变量表和操作数栈执行到 istore_3 指令时的状态。有一个需要注意的地方是,如果方法是一个对象方法,那么该方法局部变量表的 0 号地址存放的是一个 this,如果方法是静态的则没有 this

Java 方法执行是基于栈(操作数栈),兼容性好,但效率偏低一点;而 C/C++ 是基于寄存器(偏硬件),运算快但移植性差(需要 make install)。

还有栈帧区域中的动态连接,是跟 Java 语言多态特性相关的,具体术语叫做动态分派和静态分派。假设如下代码:

public class People {

    public void wc() {
        System.out.println("People");
    }

    static class Man extends People {
        @Override
        public void wc() {
            System.out.println("Man");
        }
    }

    static class Woman extends People {
        @Override
        public void wc() {
            System.out.println("Woman");
        }
    }

    public static void main(String[] args) {
        People people = new Man();
        people.wc();
        people = new Woman();
        people.wc();
    }
}

编译阶段,无法确定 people 调用的 wc() 应该是 Man 中的还是 Woman 中的,它是在执行阶段,由动态连接决定的。

2.3 本地方法栈

本地方法栈跟 Java 虚拟机栈的功能类似,Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈则用于管理本地方法的调用。native 方法由 C 语言实现,当一个 JVM 创建的线程调用 native 方法后,JVM 不会为该方法在虚拟机栈中创建栈帧,只是简单动态链接直接调用该方法。

虚拟规范对本地方法栈无强制规定,各版本虚拟机可以自由实现比如说 HotSpot 直接把本地方法栈和虚拟机栈合二为一。

线程私有的三个区域到这里介绍完毕了,下面开始介绍线程公有的方法区和 Java 堆。

2.4 方法区

方法区主要是用来存放已被虚拟加载类相关信息,包括类信息静态变量常量、运行时常量池、字符串常量池。

JVM 在执行某个类的时候,必须先加载。在加载类(加载验证准备、解析、初始化)的时候,JVM 会先加载 class 文件,而在 class 文件中除了有类的版本字段、方法和接口描述信息外,还有一项信息是常量池 (Constant Pool Table),用于存放编译期间生成的各种字面量和符号引用

而当类加载到内存中后,JVM 就会将 class 文件常量池中的内容放到运行时的常量池中;在解析阶段,JVM 会把符号引用替换直接引用(对象的索引值)。

例如类中的一个字符串常量在 class 文件中时,存放在 class 文件常量池中的;在 JVM 加载完类之后,JVM 会将这个字符串常量放到运行时常量池中,并在解析阶段,指定字符串对象的索引值。运行时常量池是全局共享的,多个类共用一个运行时常量池,class 文件中常量池多个相同字符串在运行时常量池只会存在一份。

方法区与堆空间类似,也是一个共享内存区,所以方法区是线程共享的。假如两个线程都试图访问方法区中的同一个信息,而这个类还没有装入 JVM,那么此时就只允许一个线程去加载它,另一个线程必须等待。在 HotSpot 虚拟机、Java7 版本中已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分存储在 JVM 的非堆内存中,而 Java8 版本已经将方法区中实现的永久代去掉了,并用元空间(class metadata)代替了之前的永久代,并且元空间存储位置是本地。

方法区和堆区都是线程共享的,为什么要分成两个区域呢?
堆中保存的对象和数组都是需要频繁回收的,而方法区中保存的类信息、常量、静态变量和即时编译编译后的代码,它们要被回收难度是非常大的。把二者区分开体现了一种动静分离的思想,偏静态的数据放到方法区,而频繁创建回收的对象放到堆中,便于高效的回收

永久代与元空间

关于方法区,有两个易混淆概念就是永久代(PermGen)和元空间(MetaSpace)。方法区是虚拟机中规范的一部分,而永久代和元空间都是实现这种规范方式,只不过永久代仅存在于 Hotspot 虚拟机中,而其他的虚拟机如 JRocket(Oracle)、J9(IBM)并没有永久代。并且在 Oracle 收购 Hotspot 和 JRocket 两家公司之后,为了推出 JDK 1.8 需要将两个虚拟机融合,将永久代去掉了转而使用元空间,即从 JDK 1.8 开始没有永久代了(其实从 1.7 开始就着手清除永久代了)。两者最大区别就是:元空间不在虚拟机中,而是使用本地内存

除此之外,还有以下区别

  • JDK ≤ 1.7 时代,GC 除了要回收堆内存之外,还要回收永久代中保存的类信息、常量、静态变量等,回收效率很低;而当 JDK ≥ 1.8 后,元空间完全替代了永久代。它使用的是机器内存,默认情况下,其空间大小不受限制(永久代的空间大小受制于堆的大小,而元空间只受制于机器,它可以使用直接内存,也称堆外内存)。
  • 永久代在动态生成类时容易发生内存溢出,即”java.lang.OutOfMemoryError: PermGen space“;而元空间方便拓展,但是会挤压堆空间。比如说一个机器内存为 16G,元空间占用了其中的 12G,那么使用 -Xmx 指令为堆区内存可分配最大上限的值,理论最大值只能为 4G。

Java8 为什么使用元空间替代永久代,这样做有什么好处呢?
官方给出的解释是:
移除永久代是为了融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,所以不需要配置永久代。
永久代内存经常不够用或发生内存溢出抛出异常 java.lang.OutOfMemoryError: PermGen。这是因为在 JDK1.7 版本中,指定的 PermGen 区大小为 8M,由于 PermGen 中类的元数据信息在每次 FullGC 的时候可能收集,回收率都偏低,成绩很难令人满意;还有,为 PermGen 分配多大的空间很难确定,PermSize 的大小依赖于很多因素比如,JVM 加载的 class 总数、常量池的大小和方法的大小等。

关于永久代和元空间更详细的内容可以参考以下:
Java8内存模型—永久代(PermGen)和元空间(Metaspace)
元空间和永久代的区别
别再说不知道元空间和永久代的区别了
元空间和永久代的区别

2.5 堆

堆是 JVM 上最大的内存区域,我们申请的几乎所有的对象,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。

堆空间一般是程序启动时,就申请了,但是并不一定会全部使用。

随着对象的频繁创建,堆空间占用的越来越多,就需要不定期的对不再使用的对象进行回收。这个在 Java 中,就叫作 GC(Garbage Collection)。

那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?这和两个方面有关:对象的类型和在 Java 类中存在的位置。Java 的对象可以分为基本数据类型和普通对象:

堆大小参数设置
-Xmx堆区内存可被分配的最大上限。
-Xms堆区内存初始内存分配的大小。
-Xmn:新生代的大小;
-XX:NewSize:新生代最小值
-XX:MaxNewSize:新生代最大值

可以连接了解参数详情JVM参数参考

由于堆栈常常会放在一起讨论这里我们对二者做一个辨析:

  1. 功能上:
  2. 线程共享还是独享:
    • 栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存
    • 堆内存中的对象对所有线程可见,可以被所有线访问
  3. 空间大小:栈的内存要远远小于堆内存(虚拟机栈 1M),栈的深度是有限制的,可能发生 StackOverFlowError 问题

2.6 直接内存

也称堆外内存,不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。

如果使用了 NIO,这块区域会被频繁使用,在 Java 堆内可以用 directByteBuffer 对象直接引用并操作。

这块内存不受 Java 堆大小限制,但受本机总内存的限制,可以通过 -XX:MaxDirectMemorySize 来设置默认与堆内存最大值一样),所以也会出现 OOM 异常。

直接内存

3、从底层深入理解运行时数据区

列出示例代码:

/**
 * 设置虚拟机参数:-Xms30m -Xmx30m -XX:+UseConcMarkSweepGC -XX:-UseCompressedOops
 * 参数含义依次为:堆的初始大小为30M,堆的最大值为30M,使用垃圾回收器,关闭内存压缩
 */
public class JVMObject {

    public final static String MAN_TYPE = "man"; // 常量
    public static String WOMAN_TYPE = "woman"; // 静态变量

    public static void main(String[] args) throws InterruptedException {
        Teacher t1 = new Teacher(); // t1对象本身是局部变量,在堆中;t1引用在虚拟机栈->main栈帧->局部变量表中。
        t1.setName("Teacher1");
        t1.setSexType(MAN_TYPE);
        t1.setAge(30);
        for (int i = 0; i < 15; i++) {
            System.gc();
        }

        Teacher t2 = new Teacher();
        t2.setName("Teacher2");
        t2.setSexType(MAN_TYPE);
        t2.setAge(35);
        Thread.sleep(Integer.MAX_VALUE);
    }
}

class Teacher {
    String name;
    String sexType;
    int age;
    // setters...
}

我们要使用工具来观察这段代码执行时 JVM 和底层数据的一些现象。

开始执行程序,步骤是:

  1. 首先要申请堆、栈、方法区的内存。
  2. 然后使用 ClassLoader 将 Teacher 和 JVMObject 两个类的 class 加载进方法区。
  3. 接下来对类进行分析,将类中的常量(MAN_TYPE)和静态变量(WOMAN_TYPE)加载进方法区。
  4. 开始执行 main 方法,表示该方法的栈帧进入虚拟机栈。
  5. 执行 main 方法内容,对象 t1 在堆的年轻代中创建,而 t1 的引用则要放在该方法栈帧的局部变量表中。执行过 15 次垃圾回收后,t1 会被放入堆的老年代中,随后 t2 在堆的年轻代中创建,t2 的引用放入栈帧的局部变量表

经过以上步骤执行后,JVM 的内存示意图如下

当然我们也可以使用内存可视化工具 HSDB 查看内存的具体情况。

HSDB 全名为 HotSpot Debugger,从 JDK1.8 开始存在,在 JDK1.9 可以可视化调用。JDK1.8 使用该工具的方法是:在 jdk/lib 目录下(为了运行该目录下的 sa-jdi.jar)运行 cmd 输入命令

C:Program FilesJavajdk1.8.0_181lib>java -cp .sa-jdi.jar sun.jvm.hotspot.HSDB

然后就会弹出 HSDB 工具界面。在正式使用 HSDB 之前,先使用 jps 命令查看 Java 程序的进程号:

C:Users69129>jps
11056
5136 Jps
13332 HSDB
8668 JVMObject

可以看到例程进程 id 为 8668,HSDB 实际上也是个 Java 进程,id 为 13332。

接下来在 HSDB 中选择 File -> Attach to HotSpot process -> 输入监控pid,会弹出一个 Java Threads 窗口

HSDB线程窗口

选定要查看的 main 线程,点击工具栏第二个按钮 Stack Memory:

就可以看到该线程栈区(虚拟机栈)内存的详细信息了:

图中可以清晰的看到 main 方法与 Thread.sleep 方法的栈帧以及地址范围,更可以看到 Thread.sleep 作为一个本地方法,它的栈帧与虚拟机栈存放的 main 方法栈帧相邻存放,也印证了前面说到的 HotSpot 虚拟机将虚拟机栈和本地方法栈合二为一的说法

另外可以看出一点,虚拟机不仅将方法中的指令虚拟化了,其实也是通过栈帧将真实的内存地址给虚拟化了。

接下来如何查看类及其对象的信息。在 HSDB 工具栏选择 Tools -> Object Histogram -> 输入查看类的全类名 -> 双击查看对象信息 -> Inspect 查看单个对象的详细信息

Teacher 类的两个对象都在堆区,依据就是这两个对象的地址都在堆区地址的范围内。两个对象的地址分别为 0x0000000013ac3150 和 0x0000000013000000,而我们在 HSDB -> Tools -> Heap Parameters 下能看到堆的数据:

通过以上数据可以看到堆中是进行了分代划分的,Gen0 是年轻代,Gen1 是老年代,年轻代中又分为 eden区、from 区和 to 区,可以看到 Gen0 与 Gen1 的内存区域是相连的,并且两个 Teacher 对象的地址一个在 eden 区一个在 Gen1 区,所以说这两个对象在堆区中。

4、内存溢出

示例代码需要配合虚拟机参数才能看出效果。Android Studio 中设置虚拟机参数的方法是 选择要运行的程序或模块 -> Edit Configurations -> 将参数填入 VM options 中。

4.1 栈溢出

一个方法递归调用自己,就会产生栈溢出 StackOverflowError:

/**
 * 栈溢出 -Xss1m
 */
public class StackOverFlow {

    public void king(){//一个栈帧--虚拟机栈运行
        king();//无穷的递归
    }
    public static void main(String[] args)throws Throwable {
        StackOverFlow javaStack = new StackOverFlow(); //new一个对象
        javaStack.king();
    }
}

还有一种情形是 OutOfMemoryError,比如要创建 1000 个线程同时执行,每个虚拟机栈的大小默认为 1M,那么总共需要 1000M 的内存,但是如果当前系统仅有 500M 就无法满足这 1000 个线程同时执行需要的内存,就会产生 OutOfMemoryError。

4.2 堆溢出

第一种情况,对象所需要的内存空间大于堆空间,比如使用参数设置堆区大小为 30M,然后申请一个 35M 的数组

/**
 * VM Args:-Xms30m -Xmx30m -XX:+PrintGCDetails
 * 堆内存溢出(直接溢出)
 */
public class HeapOom {
    public static void main(String[] args) {
        String[] strings = new String[35 * 1000 * 1000];  //35m的数组(堆)
    }
}

输出异常 log

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at com.frank.jvm.HeapOom.main(HeapOom.java:9)

这些 OutOfMemoryError 的实例,也可以通过 HSDB 的 Object histogram 查看。

第二种情况,频繁 GC 后出现堆溢出:

/**
 * VM Args:-Xms30m -Xmx30m -XX:+PrintGC    堆的大小30M
 * 造成一个堆内存溢出(分析下JVM的分代收集)
 * GC调优---生产服务器推荐开启(默认是关闭的)
 * -XX:+HeapDumpOnOutOfMemoryError
 */
public class HeapOom2 {
    public static void main(String[] args) {
        //GC ROOTS
        List<Object> list = new LinkedList<>(); // list   当前虚拟机栈(局部变量表)中引用的对象  是1,不是走2
        int i = 0;
        while (true) {
            i++;
            if (i % 10000 == 0) System.out.println("i=" + i);
            list.add(new Object()); // GC 频繁回收 new 出的 Object
        }
    }
}

log 如下

4.3 方法区溢出

cglib 不断的编译代码会造成方法区内存溢出,运行示例之前需要先将 cglibasm 两个 jar 包添加项目中:

/**
 * cglib动态生成
 * Enhancer中 setSuperClass和setCallback, 设置好了SuperClass后, 可以使用create制作代理对象了
 * 限制方法区的大小导致的内存溢出
 * VM Args: -XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M
 */
public class MethodAreaOutOfMemory {

    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(MethodAreaOutOfMemory.TestObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                public Object intercept(Object arg0, Method arg1, Object[] arg2, MethodProxy arg3) throws Throwable {
                    return arg3.invokeSuper(arg0, arg2);
                }
            });
            enhancer.create();
        }
    }

    public static class TestObject {
        private double a = 34.53;
        private Integer b = 9999999;
    }
}

给虚拟机设置元空间 MetaspaceSize 大小为 10M,运行异常 log 如下

cglib是一个强大的,高性能,高质量的Code生成类库,它可以在运行期扩展Java类与实现Java接口。
CGLIB包的底层是通过使用一个小而快的字节码处理框架ASM,来转换字节码并生成新的类。除了CGLIB包,脚本语言例如Groovy和BeanShell,也是使用ASM来生成java的字节码。当然不鼓励直接使用ASM,因为它要求你必须对JVM内部结构包括class文件的格式和指令集都很熟悉。

4.4 本机直接内存溢出

/**
 * VM Args:-XX:MaxDirectMemorySize=100m
 * 堆外内存(直接内存溢出)
 */
public class DirectOom {
    public static void main(String[] args) {
        //直接分配128M的直接内存(100M)
        ByteBuffer bb = ByteBuffer.allocateDirect(128*1024*1204);
    }
}

指定直接内存为 100M,申请 128M 发生溢出,log:

Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
	at java.nio.Bits.reserveMemory(Bits.java:694)
	at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
	at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
	at com.frank.jvm.DirectOom.main(DirectOom.java:14)

5、虚拟机优化技术

5.1 编译优化技术

主要的一个点是方法内联。举个简单的例子:

public class MethodDeal {

    public static void main(String[] args) {
        // max(1,2);//调用max方法:  虚拟机栈 --入栈(max 栈帧)
        boolean i1 = 1 > 2;
    }

    public static boolean max(int a, int b) {//方法的执行入栈帧。
        return a > b;
    }
}

假如编译时已经能确定 max 方法两个参数的值,那么就直接使用具体值参与计算,而不是仍然使用 max 方法。因为调用方法要生成栈帧出入虚拟机栈,耗费性能,没必要通过方法调用增加无畏的性能消耗。而方法内联就是把 max 的方法体提前到 main 中执行,减少层级,提高运行效率。

Java 官方文档中也有提到类似的优化方法。

5.2 栈的优化技术

栈帧之间数据共享。主要是在方法调用出现参数传递时,调用方的栈帧与被调用方的栈帧可以共享一部分区域来传递参数。

栈帧之间的数据共享图

示例代码:

/**
 * 栈帧之间数据的共享
 */
public class JVMStack {

    public int work(int x) throws Exception {
        int z = (x + 5) * 10; //局部变量表
        Thread.sleep(Integer.MAX_VALUE); // 休眠线程以便使用HSDB查看
        return z;
    }

    public static void main(String[] args) throws Exception {
        JVMStack jvmStack = new JVMStack();
        jvmStack.work(10); //10  放入main栈帧操作数栈
    }
}

main 中调用 work 方法,传递的参数10是放在 main 方法栈帧的操作数栈中的,而 work 方法接收的参数10是放在栈帧的局部变量表中,那么 main 栈帧的操作数栈与 work 的局部变量表就可以通过共享区域传递参数10,。可以使用 HSDB 看一下:

上图中地址尾数 096 位置的内存,是 main 方法操作数栈(黑色线)与 work 方法局部变量表(棕色线)重合的部分,表示传递参数的共享区域。

原文地址:https://blog.csdn.net/tmacfrank/article/details/134634729

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任

如若转载,请注明出处:http://www.7code.cn/show_34880.html

如若内容造成侵权/违法违规/事实不符,请联系代码007邮箱suwngjj01@126.com进行投诉反馈,一经查实,立即删除

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注