易混淆概念
- Java内存模型(Java Memory Model),简称JMM。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。
- JVM内存结构与运行时数据区有关。
文章目录
一、JVM内存结构
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些数据区域可以分为两个部分:一部分是线程共享的,一部分则是线程私有的。其中,线程共享的数据区包括方法区和堆,线程私有的数据区包括虚拟机栈、本地方法栈和程序计数器。如下图所示
关于永久代
jdk1.6及之前:常量池分配在永久代 ;
jdk1.7:有,但已经逐步“去永久代” ;
jdk1.8及之后:无(java.lang.OutOfMemoryError: PermGen space,这种错误将不会出现在JDK1.8中 );
1. 线程私有区
线程私有的数据区 包括 程序计数器、 虚拟机栈 和 本地方法栈 三个区域。
1.1程序计数器
知识点:线程是CPU调度的基本单位。在多线程情况下,当线程数超过CPU数量或CPU内核数量时,线程之间就要根据时间片轮询抢夺CPU时间资源。也就是说,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能够恢复到正确的执行位置,每条线程都需要一个独立的程序计数器去记录其正在执行的字节码指令地址。
1)程序计数器占较小的一块内存空间,可以看作是当前线程所执行的字节码的行号指示器。
--如果线程正在执行的是一个 Java 方法,计数器记录的是正在执行的字节码指令的地址;
--如果正在执行的是 Native 方法,则计数器的值为空。
2)程序计数器是唯一一个没有规定任何 OutOfMemoryError 的区域。
1.2虚拟机栈
虚拟机栈描述的是Java方法执行的内存模型,生命周期与线程相同,每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,而且每个方法从调用直至完成的过程,对应一个栈帧在虚拟机栈中入栈到出栈的过程。其中,局部变量表主要存放一些基本类型的变量(int, short, long, byte, float, double, boolean, char)和 对象句柄,它们可以是方法参数,也可以是方法的局部变量。虚拟机栈有两种异常情况:StackOverflowError 和 OutOfMemoryError。
1)StackOverflowError情况:一个线程拥有一个自己的栈,(-Xss 参数可以设置虚拟机栈大小),若线程请求的栈深度大于虚拟机允许的深度,则抛出 StackOverFlowError 异常。
2)OutOfMemoryError情况:栈的大小可以是固定的,也可以是动态扩展的,若虚拟机栈可以动态扩展(大多数虚拟机都可以),但扩展时无法申请到足够的内存(比如没有足够的内存为一个新创建的线程分配栈空间时),则抛出 OutofMemoryError 异常。
下图为栈帧结构图:
1. 3本地方法栈
本地方法栈与Java虚拟机栈非常相似,但是也有区别之处。
区别:虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈为虚拟机执行 Native 方法服务。
相同:与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。
2.线程共享区
线程共享的数据区具体包括 Java堆 和 方法区 两个区域。
2.1 Java堆
Java 堆的唯一目的就是存放对象实例。类的对象从中分配空间,这些对象通过new、newarray、 anewarray 和 multianewarray 等指令建立,它们不需要程序代码来显式的释放。由于Java堆唯一目的就是用来存放对象实例,因此其也是垃圾收集器管理的主要区域,故也称为称为 GC堆。
知识点:从内存回收的角度看,目前垃圾收集器基本都采用分代收集算法,所以为了方便垃圾回收Java堆还可以分为 新生代 和 老年代 。新生代用于存放刚创建的对象以及年轻的对象,如果对象一直没有被回收,生存得足够长,对象就会被移入老年代。新生代又可进一步细分为 eden、survivorSpace0 和 survivorSpace1。刚创建的对象都放入新生代(大对象除外)都至少经过一次GC并幸存。如果幸存对象经过一定时间仍存在,则进入老年代。
注意:Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。而且,Java堆可通过-Xmx(最大堆容量) 和 -Xms(最小堆容量)进行容量限制。如果在堆中没有内存完成实例分配,并且堆也无法再拓展时,将会抛出 OutOfMemoryError 异常。
TLAB (线程私有分配缓冲区)
Sun Hotspot JVM 为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间 TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行的情况计算而得。在TLAB上分配对象时不需要加锁(相对于CAS配上失败重试方式 ),因此JVM在给线程的对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配。
虚拟机为新生对象分配内存时,需要考虑修改指针 (该指针用于划分内存使用空间和空闲空间)时的线程安全问题,因为存在可能出现正在给对象A分配内存,指针还未修改,对象B又同时使用原来的指针分配内存的情况。TLAB的存在就是为了解决线程安全问题:每个线程在Java堆中预先分配一小块内存TLAB,哪个线程需要分配内存就在自己的TLAB上进行分配,若TLAB用完并分配新的TLAB时,再加同步锁定,这样就大大提升了对象内存分配的效率。
2.2 方法区
方法区也可以处于物理上不连续的内存空间中,用于存储已被虚拟机加载的 类信息、常量、静态变量、即时编译器编译后的代码等数据。根据Java虚拟机规范的规定,当方法区无法满足内存分配的需求时,将抛出 OutOfMemoryError 异常。
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放编译期生成的各种 字面量 和 符号引用。
1)字面量:比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等;
2)符号引用:属于编译原理方面的概念,包括以下三类常量:类和接口的全限定名、字段的名称和描述符 和 方法的名称和描述符。因为运行时常量池(Runtime Constant Pool)是方法区的一部分,那么当常量池无法再申请到内存时也会抛出 OutOfMemoryError 异常。
运行时常量池相对于Class文件常量池的一个重要特征是具备动态性。Java语言并不要求常量一定只有编译期才能产生,运行期间也可能将新的常量放入池中,比如字符串的手动入池方法intern()。
2.3 Java堆与方法区的区别
【Java堆在堆内存中,方法区在非堆内存中】:Java堆是 Java代码可及的内存,是留给开发人员使用的;而非堆(Non-Heap)是JVM留给自己用的,方法区、JVM内部处理或优化所需的内存 (如JIT编译后的代码缓存)、每个类结构 (如运行时常量池、字段和方法数据)以及方法和构造方法的代码都在非堆内存中。
2.4 方法区的回收
方法区的内存回收目标主要是针对 常量池的回收 和 对类型的卸载。
举例说明:回收废弃常量与回收Java堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个String对象是叫做“abc”的,换句话说是没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果在这时候发生内存回收,而且必要的话,这个“abc”常量就会被系统“请”出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
无用类的判断比废弃常量的判断要复杂些,类需要同时满足下面3个条件才能算是“无用的类”:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
- 加载该类的ClassLoader已经被回收;
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
二. Java对象在虚拟机中的创建与访问定位
下面以最流行的HotSpot虚拟机以及常用的内存区域Java堆为例来探讨在虚拟机中对象的创建和对象的访问等问题。
1. 对象在虚拟机中的创建过程
【(1)类加载,(2)分配内存】
(1).检查虚拟机是否加载了所要new的类,若没加载,则首先执行相应的类加载过程。虚拟机遇到new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个引用代表的类是否已经被加载、解析和初始化过。
(2). 在类加载检查通过后,对象所需内存的大小在类加载完成后便可完全确定,虚拟机就会为新生对象分配内存。一般来说,根据Java堆中内存是否绝对规整,内存的分配有两种方式:
- 指针碰撞:如果Java堆中内存绝对规整,所有用过的内存放在一边,空闲内存放在另一边,中间一个指针作为分界点的指示器,那分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相同的距离。
- 空闲列表:如果Java堆中内存并不规整,那么虚拟机就需要维护一个列表,记录哪些内存块是可用的,以便在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
除了如何划分可用空间之外,还需要考虑修改指针
(该指针用于划分内存使用空间和空闲空间)时的线程安全问题,因为存在可能出现正在给对象A分配内存,指针还未修改,对象B又同时使用原来的指针分配内存的情况。解决这个问题有两种方案:
–对分配内存空间的动作进行同步处理:采用CAS+失败重试的方式保证更新操作的原子性;
–把内存分配的动作按照线程划分的不同的空间中:每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB)【具体见上面的2.1->TLAB介绍】,哪个线程要分配内存,就在自己的TLAB上分配,如果TLAB用完并分配新的TLAB时,再加同步锁定。
(3). 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值。如果使用TLAB,也可以提前到TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
(4). 在上面的工作完成之后,从虚拟机的角度来看,一个新的对象已经产生了,但从Java程序的视角来看,对象的创建才刚刚开始,此时会执行方法把对象按照程序员的意愿进行初始化,从而产生一个真正可用的对象。
2.对象在虚拟机中的访问定位
创建对象是为了使用对象,我们的Java程序通过栈上的reference数据来操作堆上的具体对象。在虚拟机规范中,reference类型中只规定了一个指向对象的引用,并没有定义这个引用使用什么方式去定位、访问堆中的对象的具体位置。目前的主流的访问方式有使用句柄访问和直接指针访问两种。
1)句柄访问:Java堆中会划分出一块内存作为句柄池,栈中的reference指向对象的句柄地址,句柄中包含了对象实例数据和类型数据各自的具体地址信息,如下图所示。
使用句柄访问的好处:reference中存储的是稳定的句柄地址,对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,reference本身不需要修改;
2)直接指针访问:reference中存储的就是对象地址。
使用直接指针访问的好处:速度快,节省了一次指针定位的时间开销。
参考:https://blog.csdn.net/justloveyou_/article/details/71189093