Loading

JVM 一 Java内存区域

分区

Java内存区域分为五个块

深色的部分被所有线程共享,浅色的部分线程私有。

程序计数器

用来记录当前线程正在运行的字节码的行号,就是用来找运行到哪里了的一个记录指针。它很小。

如果程序都是简单的顺序执行,可能也就不需要程序计数器了,但是程序中有各种选择、分支、异常处理等结构,必须有一个指针来选择下一条要执行的字节码命令。

如果正在执行的是Java方法,计数器的值是正在执行的字节码指令的地址,如果是Native方法,则值为空。

程序计数器区域不会产生OutOfMemoryError。

虚拟机栈

虚拟机栈就是方法执行的内存模型,它是线程私有的,每个线程都会有一个虚拟机栈,线程销毁时栈也销毁。每个方法被执行时,虚拟机都会创建一个栈帧压到栈中,代表一次方法调用,栈帧中有局部变量表、操作数栈、动态连接和方法出口等信息。当方法执行结束,对应的栈帧出栈。

局部变量表中存放的就是该方法调用中的所有变量,包括基本变量类型和对象引用(不是对象,是引用)和returnAddress返回地址。

局部变量表中的数据使用槽(Slot)表示,在编译时就已经可以确定一个方法中需要多少个槽才能容纳方法中的所有变量,这个槽的数量在运行期不会改变,而有的变量占用一个槽,有的占用两个。一个槽用多大的空间来实现是Java虚拟机规范中没有限制的。

栈空间可能触发两种异常:

  1. StackOverflowError,当线程请求的栈深度大于虚拟机所允许的深度时抛出
  2. OutOfMemoryError,当虚拟机可以并被允许动态扩容栈空间,并在扩容时无法获取到足够的容量时抛出

本地方法栈

和虚拟机栈一致,只不过用来执行native方法。

堆,基本上是最受关注的内存区域,因为基本上所有对象都存在于这里,并且是GC机制主要作用的部分。

它被所有线程共享,在虚拟机启动时被创建,一个昔日准确而今已经不准确了的说法是:“所有的对象都存放在堆中”,已经不够准确了。各种优化技术,如标量替换和栈上分配等技术的出现已经可以将一些轻量级对象分配在堆以外的位置来加速程序运行了。

提到堆就不得不说垃圾清理,一提到垃圾清理就会想到什么,新生代,老年代,永久代,什么伊甸园区,幸存者区等等。其实如果执着于这些就片面了,这只是大部分JVM自己选择的分代收集方式——一种用于实现垃圾清理的方式。而JVM规范本身并未对如何实现垃圾清理做限制,就是说你完全可以编写一个脱离分代模型的JVM。

因为堆是被多个线程共享的,那么就要想办法保证堆中数据的线程安全问题,而保证线程安全就会降低实际的运行效率,TLAB(Thread Local Allocation Buffer,线程私有分配缓冲区)技术为每个线程在堆中建立了一个缓冲区,各个线程向堆中创建数据时先放到各自的缓冲区中。

Java虚拟机并不要求堆必须在一段连续的内存空间上,但它们必须逻辑连续。

Java堆可以被设计成可动态扩展容量的,也可以被设计成固定大小的,当堆不能再继续放进去实例,并且动态扩容也失败的话,就会抛出OutOfMemoryError。

方法区

方法区用来存储Class文件中的一些描述信息、类型信息,常量,静态变量等等。方法区在JVM规范中被描述成堆的一个逻辑部分,但它也有一个别名,是“非堆”,就是方法区是方法区,堆是堆,二者无法混为一谈。

早先很多人认为方法区和永久代是一回事儿,这种误会随着永久代被取缔已经差不多被根除了。JDK8以前的默认虚拟机HotSpot虚拟机使用永久代来实现方法区,这才有了这个误会。永久代给HotSpot带来了很多困难,比如更容易产生OOM(永久代有内存大小的上限),并且有一些方法会因为永久代的存在(如String.intern),会和运行在其他虚拟机上的时候产生一些细微的差别,还有就是当Oracle收购了越来越多的第三方虚拟机并想把它们的优点引入到HotSpot中时,对方法区实现的差异会产生很多麻烦。所以JDK7时很多东西从永久代中被移出,在JDK8彻底去除了永久代采用元空间来实现方法区。

Java虚拟机规范不限制这个区域必须实现垃圾回收,不限制这个区域的内存地址必须连续等。

方法区当无法满足新的内存分配需求时,抛出OOM。

运行时常量池

是方法区的一部分,当加载一个类时,类中文件中的常量池中的数据会被加载到运行时常量池中。

运行时常量池可以在运行期间动态向其中添加常量。如String.intern方法。

直接内存

虚拟机规范并未定义这块内存区域,但总会用到,它受外界物理内存和操作系统的限制,也会出现OOM。

对象的创建

程序员调用new关键字创建一个对象时,虚拟机干了啥?

首先虚拟机先去常量池中找到这个类的符号引用,找到后检测这个类是否已经被加载、解析和初始化过,如果没有则执行类加载。

然后虚拟机就会给对象分配内存,一个类需要的内存大小在类加载完后就已经可以确定了。介绍两种分配方式,第一种是指针碰撞,这种方法的前提是整个内存区域是规整的,指针左侧是已经分配过的内存区域,指针右侧是还没分配的内存区域,这时如果你想分配内存给一个对象,那么直接将指针向后移动一段距离即可,要实现这个必须在垃圾清理的时候对清理掉的内存进行压缩,整理。还有一种方式是空闲列表,这时内存空间不一定是规整的,有的地方有数据有的地方没数据,需要维护一个空闲列表,并且从这个空闲列表中找出一块足够容纳这个对象大小的空间分配给对象。

注意在多线程的环境下指针碰撞会产生线程安全问题,常见的解决办法是CAS或TLAB。

再然后,虚拟机会给对象分配到的内存空间初始化为0值。

然后对对象进行一些设置,设置对象头中的信息。

从虚拟机的视角来看,一个对象现在已经创建完毕,但是从程序员的视角来看,构造函数还没有被调用。如果是使用new方式来创建的对象,编译器会转换成两条指令,一个是new字节码命令,就是刚刚的那些步骤,一个是invokespecial,这个指令用于调用对应的构造函数,但也有其他的对象构造方式,不执行这个构造方法。

对象内存布局

对象在堆内存中的布局在HotSpot虚拟机中如下:对象头,实例数据,填充数据。

对象头中存储的就是类似哈希码,GC分代年龄,锁状态等等信息。

实例数据是对象的实例信息内容,比如其中的属性,无论是父类中的还是自己的。HotSpot的默认分配顺序是longs/doublesintsshort/charsbytes/booleansoops,就是同等大小的数据类型一起分配,然后在这个前提下,父类的数据先被分配。

填充数据就是有些虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,所以如果没有对齐时就填充齐。

对象的访问定位

栈如何通过reference来操作堆上的对象,主流的定位方法有两种

第一种是句柄访问,就是在堆中再划分出一个额外区域用来存储所有对象的实例地址和类型数据地址,而reference保存的是对象的句柄地址。这样就是栈通过reference去句柄池中找到真实地址,再去访问真实地址。

第二种是直接访问,就是reference存储的直接是对象在堆中的地址,但这样对象的类型数据地址就必须想办法在对象的内存布局中存储,比如在对象头中添加一个新字段指向对象类型地址。

posted @ 2021-08-31 13:04  yudoge  阅读(45)  评论(0编辑  收藏  举报