虚拟机内存结构以及虚拟机中销毁和新建对象

Posted 毛奇志

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了虚拟机内存结构以及虚拟机中销毁和新建对象相关的知识,希望对你有一定的参考价值。

一、前言

二、JVM的内存结构

2.1 对象、成员变量和局部变量

关于对象和变量在JVM中的存储,我们需要搞懂三个问题,分别是:
(1) 对象:一个对象在哪里?一个对象的引用放在哪里?
(2) 成员变量:一个成员变量存在哪里?
(3) 局部变量:一个局部变量存在哪里?如果是局部变量是一个对象的引用存在哪里?

三个回答:
(1) 对象:对象在堆,对象的引用放到声明对象引用的方法栈中;
(2) 成员变量:成员变量在堆(解释:因为成员变量是在类中定义的,是某一个对象的成员变量,所以也随对象放到堆里面);
(3) 局部变量:局部变量是在方法中定义的,所以在虚拟机栈的局部变量表,局部变量的引用也存放在虚拟机栈的局部变量表。

小结:对于运行时数据区,对象存放在堆中,成员变量跟随对象存放在堆中,特殊地,成员变量为static修饰的静态变量时,存放在方法区;局部变量(类中基本类型局部变量,类中引用类型局部变量,方法形参中基本类型局部变量,方法形参中引用类型的局部变量,构造方法中基本类型局部变量,构造方法中引用类型局部变量)运行时都在帧栈中的局部变量表中。

让我们来看一个简单例子,如下:

class BirthDate {    
    private int day;      // 类中定义的成员变量
    private int month;     // 类中定义的成员变量
    private int year;         // 类中定义的成员变量
    public BirthDate(int d, int m, int y) {    
        day = d;     
        month = m;     
        year = y;    
    }    
    // 省略get,set方法………    
}    
public class Test{    
    public static void main(String args[]){    
        int date = 9;     // main()方法中的局部变量
        Test test = new Test();      // test引用放到main()方法的方法栈中,new Test()对象放到堆中     
        test.change(date);     // 局部变量作为方法实参
        BirthDate d1= new BirthDate(7,7,1970);           
    }      
    public void change(int i){    
        i = 1234;    // i放在change()方法栈的局部变量表中
    }
}

对于上面这代码分析:date为局部变量,i,d,m,y都是形参为局部变量,day,month,year为成员变量。下面分析一下代码执行时候的变化:

(1) main方法开始执行,创建栈帧并压栈
int date = 9;
存放位置:因为date是局部变量,是基础类型,所以,位于虚拟机栈的局部变量表中。

(2) Test test = new Test();
存放位置:因为test是局部变量,是对象引用,所以,位于虚拟机栈的局部变量表中,对象new Test()存在堆中。

(3) test.change(date); 看到public void change(int i)方法
存放位置:i为局部变量,int是基础类型,位于虚拟机栈的局部变量表。
调用方法:当调用方法change时,创建栈帧(里面包含了局部变量表)并压栈;
方法执行完成:当方法change执行完成后,栈帧出栈,i也就消失了,该栈帧可以被回收。

(4) BirthDate d1= new BirthDate(7,7,1970);
存放位置:d1是局部变量,是对象引用,位于虚拟机栈的局部变量表中,对象new BirthDate()存在堆中。
调用方法:调用构造方法,创建栈帧并压入虚拟机栈,一个方法就是一个帧栈,新建的栈帧中的局部变量表存储了基础类型int的d,m,y。而day,month,year为成员变量,它们存储在堆中(new BirthDate()里面)。
方法执行完成:当BirthDate构造方法执行完之后,栈帧出栈,d,m,y将从栈中消失,栈帧可被回收。

(5) main方法执行完之后
存放位置:date为局部变量,test,d1引用都在栈帧中,栈帧出栈,可被回收,堆中的new Test(),new BirthDate()将等待垃圾回收。

2.2 运行时数据区域

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。下图就是运行时数据区,一共包括

这些组成部分一些是线程私有的,其他的则是线程共享的。

线程私有的:程序计数器、虚拟机栈、本地方法栈

线程共享的:堆、方法区、直接内存

2.3.1 程序计数器

定义:程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完。

内容:执行普通方法(非native方法)时,这个计数器记录的是正在执行的虚拟机字节码指令的地址;当执行本地Native方法时,计数器的值为空(undefined)。

线程私有:为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

程序计数器主要有两个作用:

(1) 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
(2) 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

注意:程序计数器是唯一不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

问题:程序计数器中,如果当前执行的方法是非native的,那pc寄存器就保存java虚拟机正在执行的字节码指令的地址,如果该方法是native的,那pc寄存器的值是undefined。那就有疑问了,在之前学过的C语言中,pc指针都是指向下一行指令的地址后才执行上一行指令的,这样调用其他方法时,压栈保存现场,这样返回后便于恢复现场和进行接下来的操作。那这里似乎解释不通?

回答:

第一,引入栈帧:在java虚拟机中,有个叫栈帧的东西,栈帧是用来存储数据结构和部分过程结果的数据结构,同时也用来处理动态链接、方法返回值和异常分派。栈帧随着方法调用而创建,随着方法结束而销毁(无论是方法正常执行完成还是抛出了在方法内未被捕获的异常都算方法结束)。每一个栈帧都有自己的本地变量表、操作数栈和指向当前方法所属的类的运行时常量池的引用。本地变量表和操作数栈的容量在编译期确定。

第二,引入当前帧栈:这里来解释一下物理计算机的pc寄存器为什么不是指向下一行指令地址而是指向当前指令,答案就是当前帧栈。如果当前方法调用了其他方法,或者当前方法执行结束。那这个方法的栈帧就不再是当前栈帧了。调用新的方法时,新的栈帧也会随之而创建,并且会随着程序控制权移交到新方法而成为新的当前栈帧。方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,然后,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

第三,方法正常返回+方法异常返回:

(1) 如果方法正常完成,它很可能会返回一个值给调用它的方法。方法正常完成发生在一个方法执行过程中遇到了方法返回的字节码指令时,使用哪种返回指令取决于方法返回值的数据类型(如果有返回值)。在这种场景下,当前栈帧承担着恢复调用者状态的责任,包括恢复调用者的局部变量表和操作数栈,以及正确递增程序计数器,以跳过刚才执行的方法调用指令等。调用者的代码在被调用方法的返回值压入调用者栈帧的操作数栈后,会继续正常执行。
(2) 如果方法调用异常完成,也就是说,虚拟机抛出的异常在该方法中没有办法处理,或者在执行过程中直接遇到athrow字节码指令来显式抛出异常,同时在该方法内部没有捕获异常。那一定不会有返回值返回给其调用者。

局部变量表:

操作数栈:

动态链接:

方法调用完成(方法出口):

2.2.2 Java虚拟机栈

问题:虚拟机栈和帧栈的关系?
回答:一个线程中有一个虚拟机栈,一个虚拟机栈中有很多个帧栈,一个方法调用对应一个帧栈,即两者的关系是:Java虚拟机栈是由一个个栈帧组成,每个栈帧中都拥有局部变量表、操作数栈、动态链接、方法出口信息。

问题:从方法调用来阐述虚拟机栈和帧栈?
回答:
(1) Java程序有多个线程,一个线程中有一个虚拟机栈,Java虚拟机栈也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡,描述的是 Java 方法执行的内存模型。
(2) 方法调用的时候,每一次方法调用创建一个帧,表示帧栈压栈;每一个方法执行完成,表示帧栈出栈。
(3) 方法调用中的两种异常:Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。
StackOverFlowError: 若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverFlowError异常。
OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError异常。

一般来说,Java 内存可以粗糙的区分为堆内存(Heap)和栈内存(Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 局部变量表主要存放了编译器可知的八种值类型(boolean、byte、char、short、int、float、long、double)、三种对象引用(类、接口和数组统称reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

2.2.3 本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。在虚拟机规范中,对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由的实现它。比如在 HotSpot 虚拟机中本地方法栈和虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。

2.2.4 堆

堆共享:整个HotSpot实现的JVM中,只有一个堆,存放三种引用类型对象,即类、接口、数组,所有线程共享这一个堆;

堆的GC操作:堆就是GC回收的区域,堆分为三个区域,新生代、老年代、永生代,jdk8变为新生代、老年代、元空间,永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制。

线程共享:Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap).从垃圾回收的角度,对于分代GC来说,堆也是分代的,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代。再细致一点,新生代有:Eden空间、From Survivor、To Survivor空间等。从内存分配的角度来看,线程共享的java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是更好地回收内存,或者更快地分配内存。

在 JDK 1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域,区别在于永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,受本机的物理内存限制而不是JVM堆内存限制。

eclipse–>Run Configurations–>Arguments–>VM arguments里面输入-XX:+PrintGCDetails,然后随便运行一段程序会看到如下:

jdk1.7:

jdk1.8:

eden space就是对象创建的地方,以jdk1.8为例:

低边界为起始位置,当前边界为当前所申请分配到的可使用的位置,最高边界为所能申请到的最高的位置

用当前边界0x0000000784980000减去低边界0x0000000780980000除以1024除以1024=64M,意味着新生代分配了64M=65536K,正好eden space49152K + from space8192K + to space 8192K=65536K=64M。

但是64M又和total 57344K不一样,说明分配的65536K的新生代,可用的只有57344K,57344K=49152K+8192K(eden space+ from space)

官方推荐新生代占堆的的3/8,幸存代占新生代的1/10。

根据java虚拟机规范规定,JVM堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制,如果两个设置成一样说明不可扩展)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

2.2.5 方法区

方法区存放的数据特征:相对静止的数据但是并不永久

很多人愿意把HotSpot 虚拟机中方法区称为 “永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为 HotSpot 虚拟机设计团队选择把GC分代收集扩展至方法区,但永久代不等同于方法区。这样 HotSpot 虚拟机的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了,能省去专门为方法区编写的内存代码的工作。

Java虚拟机规范对方法区的限制非常宽松,除了和JVM堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。所以,垃圾收集行为在这个区域是比较少出现的,但是,这并不意味着,数据进入方法区就 “永久” 存在了,这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。方法区是保存相对静止稳定的数据,但并非数据进入方法区后就“永久存在”了,可能会删除后重新加载,比如热加载、热替换。当方法区无法满足内存分配需求时,将会抛出OutOfMemoryError异常。

方法区存放被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。

方法区这个概念仅限于HotSpot虚拟机

对于其他虚拟机(如BEA HRockit、IBM J9等)来说是不存在永久代的概念的。现在来看,使用永久代来实现方法区并不是一个好主意,因为这样更容易遇到内存溢出问题(永久代有-XX:MaxPermSize的上限,J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB,就不会出现问题),而且有极少数方法(例如String.intern())会因为这个原因导致不同虚拟机下有不同的表现。

特殊地,String作为final修饰的常量放在哪里

jdk1.6运行时常量池存在于方法区,jdk1.7移到了堆区,而jdk1.8运行时常量池其实是存在于与方法区和堆区相对独立的元空间

2.2.6 运行时常量池

Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。如下:

一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,java语言并不要求常量一定只要编译器才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用的比较多是String类的intern()方法。当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

jdk1.6运行时常量池存在于方法区,jdk1.7移到了堆区,而jdk1.8运行时常量池其实是存在于与方法区和堆区相对独立的元空间

2.2.7 直接内存

直接内存定义:直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。

直接内存的使用:JDK1.4中新加入的 NIO 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在 JVM 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

直接内存受限于物理内存,产生OOM:本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小(包括RAM以及SWAP区或分页文件)以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

三、销毁对象

JVM所谓的自动内存管理,就是自动内存回收机制。
Java和C/C++区别:new 新建对象是一样的,但是释放对象,C/C++使用 delete/free 操作,程序员自己负责,Java使用自动内存回收机制,程序员不干预。

问题:GC是在什么时候?对什么东西?做了什么事情?
回答:

问题1:GC是在什么时候?
回答1:GC分为两种minor gc和full gc,eden满了就执行minor gc,老年代的对象大于老年代剩余空间就执行full gc,或者小于时被HandlePromotionFailure参数强制full gc。

1.2 OOM的触发条件:OOM:gc与非gc时间耗时超过了GCTimeRatio的限制引发OOM
1.3 降低GC的调优的策略:
调优诸如通过NewRatio控制新生代老年代比例,通过MaxTenuringThreshold控制进入老年前生存次数等

问题2:对什么东西GC?
回答2:从gc root开始搜索,搜索不到(root根对象查找、标记),即GC不可达,就是说明这个对象已经没用了,而且经过第一次标记、清理后,仍然没有复活的对象。 实际上就是对不使用的对象进行GC操作,这里补上JVM四种引用对象,优先级:强引用FinalReference、GC操作、软引用SoftReference、弱引用WeakReference、虚引用。

问题3:“GC做什么事情”,这个问发挥的空间就太大了,不同年代、不同收集器的动作非常多。
回答3:
(1) 宏观上说:删除不使用的对象,腾出内存空间。
(2) 从JVM上说:整个过程,达到GC stop point点,停止其他线程执行、运行finalize。
(3) 不同的垃圾收集算法:对于新生代和老年代的清理方式是不同的,新生代做的是复制清理,因为新生代99%的对象都是朝生夕死的,所以在结构上,没有必要留下50%的空闲区作为复制,只要留下10%就好(就算超出也有老年代担保机制,从老年代借一些空间来),结构上,eden:survivor=8:2,将存活的对象放到from survivor区,复制到to survivor,整理内存,就完成了复制清理。老年代做的是标记清理、标记清理后碎片要不要整理、复制清理和标记清理有有什么优劣势等。
(4) 基于垃圾收集算法的垃圾收集器:垃圾收集算法是理论基础,垃圾收集器是具体实现,新生代和老年代都有垃圾收集器,串行、并行(整理/不整理碎片)、CMS等搜集器可作用的年代、特点、优劣势,并且能说明控制/调整收集器选择的方式。

问题4:为什么Java没有析构函数?
回答4:Java中有没有析构函数不重要,应该说,Java作为一门后端编程语言,有能够完成C/C++中析构函数相同功能的内存回收机制,就是重写finalize()方法 和 垃圾收集器,就像Java没有格式化输出,而是提供System.out.println()方法通过字符串拼接提供相同的功能一样。
解释1:java并没有提供析构函数或相似的概念,代之以java的垃圾回收机制,但是垃圾回收并不等同于“析构”,而作为垃圾回收机制的一部分,finalize()方法更不能视为c++中的析构函数。这是因为:如果C++程序没有缺陷,那么析构函数会被自动调用并完成清理工作,这一动作是一定会执行的;而java的垃圾回收只与内存有关,只要程序没有濒临存储空间耗尽,那么与垃圾回收有关的任何行为(尤其是finalize()方法)都不会执行,换言之,java里的对象并非一定会被垃圾回收!进而,如果java想要实现类似c++析构函数的清理效果,必须编写恰当的清理方法,并明确调用,而不是依靠finalize()方法,还是那句话,JVM如果没有面临内存耗尽,它不会浪费时间执行垃圾回收以恢复内存,遑论这期间调用finalize()方法的环节,所以并不能保证finalize()方法一定执行,这(极大)可能会达不到你想要的清理效果。关于finalize()方法的应用场景,我觉得java编程思想中将其作为“终结条件”来验证对象清理回收前的状态以检查编程缺陷是很有启发性的。
解释2:java中有析构函数,但我们一般用不到它,因为java有自动内存回收机制,无需程序员来释放,也就不要担心内存泄露,只不过java中析构函数所采用的方式不是C++中的那样前加~号,在java中
对象析构时会调用void finalize()方法,因此你如果确实需要析构的话就可以为你写的类添加一个void finalize(){}方法,来完成你所需要的工作
小结:解释1的意思是,C/C++中内存回收的时机(析构函数的调用时机)是程序员自己指定,Java中内存回收的时机(垃圾收集器收集内存的时机)由JVM自动确定,所以Java和C/C++不同。
解释2的意思是,C/C++中内存回收前的操作,程序员可以自己指定,写在析构函数中;Java中内存回收前的操作,程序员可以自己指定,写在重写的finalize()方法中,所以Java和C/C++相同。
一个回收内存的时机,一个是回收内存的操作。

四、对象创建

通过上面的介绍我们大概知道了虚拟机的内存情况,下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。

4.1 对象创建

下图便是 Java 对象的创建过程,我建议最好是能默写出来,并且要掌握每一步在做什么。

Java创建对象过程

(1) 类加载检查(加载-验证-链接(准备-解析-初始化)): 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载、验证、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

(2) 分配内存: 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

(3) 初始化零值: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

(4) 设置对象头(markword + Class Metadata Address + 数组长度 ): 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

(5) 调用构造函数,底层执行 init 方法: 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

新建对象第二个步骤(即分配内存),内存分配的两种方式:指针碰撞分配内存、空闲列表分配内存,如下图:

选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是”标记-清除”,还是”标记-整理”(也称作”标记-压缩”),值得注意的是,复制算法内存也是规整的。

创建对象在JVM中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发的情况下也不是线程安全的,可能正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来指针指向的位置去分配内存。解决这个问题方案有2种,如下:

(1) CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
(2) TLAB: 为每一个线程预先在 Eden 区分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,当对象大于TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。

关于内存分配的区域:

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。如果设置了-XX:PretenureSizeThreshold参数,则就会将大于这个设置值的对象直接在老年代分配。这样做到目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。因为虚拟机采用分代收集的思想管理内存,所以内存回收的时候就必须要识别哪些对象应放在新生代,哪些对象放在老年代。为了实现,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden区出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间,并且对象年龄设为1。对象在Survivor区每“熬过”一次GC,年龄就增加一岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

为了能更好的适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入到老年代,无须等到MaxTenuringThreshold中要求的年龄。

4.2 对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为3块区域:对象头(mark word + class metadata address + 数组长度)、实例数据和对齐填充。

Hotspot虚拟机的对象头包括两部分信息:第一部分用于存储对象自身的自身运行时数据(哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等),这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为“Mark Word”。考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须要在对象数据上保留类型指针,换句话说,查找对象的元数据并不一定要经过对象本身,这涉及到对象的访问定位,我们稍后再讲。另外,如果对象是一个java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为JVM可以通过普通java对象的元数据信息确定java对象的大小,但是从数组的元数据中却无法确定数组的大小,必须从数组长度中看出。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。无论是从父类继承下来的,还是子类中定义的,都需要记录下来。

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

以HotSpot的直接指针访问对象为例,对象是对象,对象头是对象头。
对象头包括:markword、class metadata address、数组长度
对象包括:对象头、对象实例、对齐填充。

4.3 对象的两种访问方式

建立对象就是为了使用对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式取决于虚拟机实现而定,目前主流的访问方式有使用句柄和直接指针两种:

(1) 句柄: 如果使用句柄的话,那么Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

(2) 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。下图所示,reference指向对象,对象里面有到对象类型数据的指针。


句柄来访问好处:reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据的指针,而reference本身不需要修改。

直接指针访问好处:速度更快,它节省了一次指针定位的时间开销,由于对象的访问在java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

就HotSpot而言,它是使用直接指针的访问方式进行对象访问的,但是从整个软件开发范围来看,各种语言和框架使用句柄来访问的情况也十分常见。

五、尾声

虚拟机内存结构以及虚拟机中销毁和新建对象,完成了。

天天打码,天天进步!!

以上是关于虚拟机内存结构以及虚拟机中销毁和新建对象的主要内容,如果未能解决你的问题,请参考以下文章

第二篇:虚拟机内存结构以及虚拟机中销毁和新建对象

第二篇:虚拟机内存结构以及虚拟机中销毁和新建对象

JVM 内存模型概述

第二章:Java内存区域

深入理解java虚拟机HotSpot Java对象创建,内存布局以及訪问方式

java虚拟机java内存区域与内存溢出异常