[JVM]复习:JVM内存分配相关

469 阅读11分钟

JVM 是一种规范

JVM 全称 Java Virtual Machine。它是运行在操作系统中的一个虚拟计算机,能够识别 .class 并将其解析成对应的指令。JVM 是一种规范,也就是说你完全可以用底层语言根据其规范去写一个自己的虚拟机。JVM 主要有两个特点:

  • 1️⃣ JVM 具有语言无关的特性

除了 Java 可以在 JVM 上运行,常见的还有 KotlinGroovy。有很多语言都是运行在 JVM 上的,这些不同语言的作用就是将代码转化成 字节码(.class) 文件,然后 JVM 再翻译成机器能够执行的代码,换言之,我们自己也可以写一门运行在 JVM 上的语言,这门语言最终能转化成可以在 JVM 上执行的字节码文件就好了。

  • 2️⃣ JVM 具有跨平台的特性

这个就不用多说了,我们都知道同样的java程序能在不同的操作系统上执行得到相同的结果。这就是 JVM 的功劳。

Java 的编译执行过程

image.png

✔️ 整个过程如下:Java 文件 -> 编译器 -> 字节码-> JVM -> 机器码 -> 执行。

我们编写的 java 代码会通过 javac 这个工具帮我们在编译时转化成 字节码 文件,然后通过 ClassLoader 加载, 再交给执行引擎去执行。但是执行有两种方式,一种是 解释执行,另一种是 JIT执行

✈️ 解释执行 :意思就是解释一行语句,执行一语句。我们知道 JVM 是由C++编写的,在执行 Java 中的语句时,将翻译好的 .class 文件拿到 C++解释器 中去解释执行,由C++代码去实现Java代码中的逻辑。

✈️ JIT执行 :由 JIT编译器(Just In Time Compiler) 将一些高频率运行的方法或者代码块标记为热点代码,将其直接翻译成机器码去执行。这种方式显然快于解释执行。

JVM 的内存

JVM 的内存主要由 运行时数据区堆外内存 构成。运行时数据区 是 JVM 虚拟化出的内存,而堆外内存是没有被虚拟化的,因此又叫 直接内存

image.png

一. 运行时数据区

运行时数据区分为 线程共享区线程私有区

A. 线程共享区

运行时数据区里面包含的东西有很多,我们慢慢来说。

1.方法区(Method Area)

方法区 是可供各条线程共享的运行时内存区域。它存储了每一个类的结构信息,例如 运行时常量池方法数据构造函数普通方法 的字节码内容。

⛪ 方法区是 JVM 对内存的 逻辑划分 ,在 JDK1.7 及之前很多开发者都习惯将方法区称为 永久代,是因为在 HotSpot 虚拟机中,设计人员使用了永久代来实现了 JVM 规范的方法区。在 JDK1.8 及以后使用了 元空间 来实现方法区。

2.堆(Heap)

Java 中,几乎所有的对象都是在 中创建的,比如 new Person() 这时候就在 中创建出了 Person 类,而我们写出的 Person p = new Person() 这一步, 实际上是在当前 栈帧 中的 局部变量表 中用 p 对堆里面这个对象进行了引用。这里我们可以看出为什么 是被定义在 线程共享区 中了(❗因为每个线程都能引用堆中的对象)。

☑️ 刚刚说到 几乎所有的对象都在堆中创建,言外之意还有一小部分的对象不是在堆中创建的。对于基本数据类型来说,当方法体内声明了基本数据类型的对象,它就会在栈上直接分配。JVM 会进行 逃逸分析 ,如果这个对象不会在除了本方法外的地方有引用,换句话说就是只在当前线程中的方法中引用,就没有必要将它分配到 中去,而是进行栈上分配,因为 有一个 共享区 的概念。

中又分为 新生代(Eden + From + To)老年代(Tenured)。对象分配内存时会优先分配到Eden区,但是如果是大对象的话将直接分配到老年代。在 GC(垃圾回收) 时,不符合条件的对象的 age值 + 1,如果 age值 大于15(该值用 4 个比特位记录,因此最大为15),对象将从新生代走到老年代。在垃圾回收时,逃离垃圾分配机制的次数越多,该对象越不容易被 GC

新生代 中又有 From 区和 To区的区分,主要是为了能提高垃圾回收时提高空间利用率。这里涉及到 Appel式 复制算法,即每次垃圾回收时复制 Eden区From或者To区的一个(空出来的那一个用来接收复制过来的对象) ,然后将其存放到 To或者From区的一个中,一般来说 EdenFromTo 的比例为 8 : 1 :1,因此空间利用率达到了 90%。

image.png

B. 线程私有区

说完了线程共享区,我们现在来看看线程私有区里的东西。线程私有区其实就是用来放一个个线程的,所有启动的线程在这里储存。线程概念就不再多说了,我们来看看线程里面更细一点的东西。

😊 下面是一个线程中的具体结构: image.png

本地方法栈和虚拟机栈功能很相似,本地方法栈只是调用一些 native 方法,因此 HotSpot 将二者合并在了一起,统一叫作虚拟机栈。

1.虚拟机栈(Java Virtual Machine Stack)

虚拟机栈的作用是在 JVM 运行过程中存储当前线程运行方法所需的数据,指令、返回地址。它有着栈的数据结构即先进后出(FILO)。它是有内存限制的,默认是1m。

⛺ 每一个方法的执行都会产生一个栈帧,比如我们程序中的 main() 是该线程第一个执行的方法,就会率先入栈,而在 main() 中有一个 todo() ,此时 todo() 也会产生一个栈帧并入栈,当 todo() 执行完毕后,该栈帧就会被弹出,紧接着 main() 执行完毕也会弹出。当虚拟机栈中没有了栈帧,其生命周期也就结束了,而这个虚拟机栈也会紧接着结束。

其中的局部变量表、操作数栈以及完成出口又是什么呢?

  • 局部变量表

✔️ 顾名思义就是局部变量的表,用于存放我们的局部变量的(方法中的变量)。首先它是一个 32 位的长度,主要存放我们的 Java 的八大基础数据类型,一般 32 位就可以存放下,如果是 64 位的就使用高低位占用两个也可以存放下,如果是局部的一些对象,存放的就是一个引用地址。

  • 操作数栈

✔️ 存放执行的操作数的,它就是一个栈,先进后出的栈结构,操作数栈,就是用来操作的,操作的的元素可以是任意的 java 数据类型,所以我们知道一个方法刚刚开始的时候,这个方法的操作数栈就是空的。比如我们将局部变量表中的 35 相加,要先将 35 从局部变量表加载到 操作数栈 中,然后在这里相加。

我们通过字节码来看看整个过程 ⬇️

//截取部分字节码
   L0
    LINENUMBER 7 L0
    ICONST_3            ----> 将 3 压入操作数栈中
    ISTORE 1            ----> 弹出操作数栈顶的元素(3),储存到局部变量表的第 2 个位置
   L1
    LINENUMBER 8 L1
    ICONST_5             ----> 将 5 压入操作数栈中
    ISTORE 2             ----> 弹出操作数栈顶的元素(5),储存到局部变量表的第 3 个位置
   L2
    LINENUMBER 9 L2
    ILOAD 1              ----> 将局部变量表第 2 个位置上的元素加载到操作数栈中
    ILOAD 2              ----> 将局部变量表第 3 个位置上的元素加载到操作数栈中
    IADD                 ----> 执行相加操作 结果等于 8
    ISTORE 3             ----> 弹出操作数栈顶的元素(8),储存到局部变量表的第 4 个位置
   L3
    LINENUMBER 10 L3
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    ILOAD 3
    INVOKEVIRTUAL java/io/PrintStream.println (I)V
   L4
    LINENUMBER 11 L4
    RETURN
   L5
    LOCALVARIABLE args [Ljava/lang/String; L0 L5 0
    LOCALVARIABLE a I L1 L5 1
    LOCALVARIABLE b I L2 L5 2        ----> 这三个都是变量名
    LOCALVARIABLE c I L3 L5 3
  • 完成出口

✔️ 把返回值(如果有的话)压入调用者栈帧的操作数栈中并调整程序计数器的值以指向该方法调用指令后面的一条指令。那什么又是程序计数器呢?

2.程序计数器(Program Counter)

程序计数器 是一块很小的内存空间,当前线程执行的字节码的行号指示器,主要用来记录执行的字节码的地址,例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。就是有了 程序计数器 ,我们写的程序才能按照正常的顺序执行。如果果是遇到本地方法,这个方法不是 JVM 来具体执行,所以此时 程序计数器 的值为空。

❗❗ 程序计数器是 JVM 中唯一不会 OOM 的内存区域


二. 直接内存

直接内存也叫堆外内存。JVM 在运行时,会从操作系统中申请大块的堆内存,进行数据的存储。同时的还有虚拟机栈、本地方法栈和程序计数器,这块称之为栈区。操作系统剩余的内存也就是堆外内存。它不是虚拟机运行时数据区的一部分,也不是 java虚拟机 规范中定义的内存区域,因此这块内存不受堆大小限制,但受本机总内存的限制。在堆内可以用 directByteBuffer 对象直接引用并操作。

对象内存分配规则

前面说到,大部分的对象都是在 中进行创建的,那么 JVM 在堆中为对象分配内存的规则又是怎么样的呢?

JVM 在堆中为对象分配内存的规则有以下 4 个:指针碰撞空闲列表CASTLAB

1. 指针碰撞

⭐ 如果堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为 指针碰撞

比如我们要分配一个3个空间大小的对象,指针只需向后移动 3 个空间。

image.png

2. 空闲列表

⭐ 如果 Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为 空闲列表

image.png

3. CAS

CAS 全称 Compare And Swap。在进行对象分配内存前,线程将会读取堆中那块即将分配的内存区的值 old(对象如果成功分配到内存区,内存区的值将会改变),在即将分配时再次读取该内存区的值,如果和 old 值相等,那么就成功分配,如果不等就再次循环上述过程。这样就能避免多个线程同时操作一个内存空间。

4. TLAB

TLAB 是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在堆中预先分配一小块私有内存,也就是本地线程分配缓冲(Thread Local Allocation Buffer)。JVM 在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个 Buffer,如果需要分配内存,就在自己的 Buffer 上分配,这样就不存在竞争的情况,可以大大提升分配效率,当 Buffer 容量不够的时候,再重新从 Eden 区域申请一块继续使用。

TLAB 的目的是在为新对象分配内存空间时,让每个线程能在使用自己专属的分配指针来分配空间,减少同步开销。TLAB 只是让每个线程有私有的分配指针,但底下存对象的内存空配指针 top 撞上分配极限 end 了),就新申请一个 TLAB

image.png

CASTLAB 都是用来解决 JAVA 中并发安全问题的