写点什么

如何正确理解 Java 对象创建过程,我们主要需要注意些什么问题?

作者:PivotalCloud
  • 2022 年 8 月 30 日
    广东
  • 本文字数:10469 字

    阅读完需:约 34 分钟

如何正确理解Java对象创建过程,我们主要需要注意些什么问题?

苍穹之边,浩瀚之挚,眰恦之美; 悟心悟性,善始善终,惟善惟道! —— 朝槿《朝槿兮年说》


写在开头


从接触 Java 开发到现在,大家对 Java 最直观的印象是什么呢?是它宣传的 “Write once, run anywhere”,还是目前看已经有些过于形式主义的语法呢?有没有静下心来仔细想过,对于 Java 到底了解到什么程度?


自从业以来,对于 Java 的那些纷纷扰扰的问题,我们或多或少都有些道不明,说不清的情绪,一直心有余悸,甚至困惑着我们的,可曾入梦。


是不是有着,不论查阅了多少遍的资料,以及翻阅了多少技术大咖的书籍,也未能解开心里那由来已久的疑惑,就像一个个未解之谜一般萦绕心扉,惶惶不可终日?


我一直都在问自己,一段 Java 代码的中类,从编写到编译,经过一系列的步骤加载到 JVM,再到运行的过程,它究竟是如何运作和流转的,其机制是什么?我们看到的结果究竟是如何呈现出来的,这其中发生了什么?


虽然,从学习 Java 之初,我们都会了解和记忆,以及在后来大家在提及的时候,大多数都是一句“我们应该都不陌生”,甚至“我相信大家都了然于心”之类话“蜻蜓点水”般轻描淡写。


但是,如果真的要问一问的话,能详细说道一二的,想必都会以“夏虫不可语冰“的悲剧上演了吧!作为一名 Java Develioer 来说,正确了解和掌握这些原理和机制,早已经不是什么”不能说的秘密“。


带着这些问题,今日我们便来扒一扒一个 Java 对象中的那些枝末细节,一个 Java 对象是如何被创建和执行的,我们又该如何理解和认识这些原理和机制,以及在日常开发工作中,我们需要注意些什么?

关健术语


本文用到的一些关键词语以及常用术语,主要如下:


  • 指针压缩(CompressedOops) : 全称为 Compressed Ordinary Object Pointer,在 HotSpot VM 64 位(bit)虚拟机为了提升内存使用率而提出的指针压缩技术。主要是指将 Java 程序中的所有对象引用指针压缩一半,主要阐述的是一个指针大小占用一个字宽单位大小,即就是 HotSpot VM 64 位(bit)虚拟机的一个字宽单位大小是 64bit,在实际工作时,原本的指针会压缩成 32bit,Oracle JDK 从 6 update 23 开始在 64 位系统上开始支持开启压缩指针,在 JDK1.7 版本之后默认开启。

  • 指针碰撞(Bump the Pointer), 指的 Java 对象为分配堆内存的一种内存分配方式,其分配过程是把内存分为已分配内存和空间内存分别处于不同的一侧,主要通过一个指针指向分界点区分。一般 JVM 为一个新对象分配内存的时候,把指针往往空闲内存区域移动指向相同对象大小的距离即可。一般适用于 Serial 和 ParNew 等不会产生内存碎片,且堆内存完整的收集器。

  • 空闲列表(Clear Free List): 指的 Java 对象为分配堆内存的一种内存分配方式,其分配过程是把内存分为已分配内存和空间内存相互交错,JVM 通过维护一张内存列表记录的可用空间内存块,创建新对象需要分配堆内存时,从列表中寻找一个足够大的内存块分配给对象实例,同步更新列表记录情况,当 GC 收集器发生 GC 时,把已回收的内存更新到内存列表。一般适用于 CMS 等会产生内存碎片,且堆内存不完整的收集器。

  • 逃逸分析(Escape Analysis): 在编程语言的编译优化原理中,分析指针动态范围的方法称之为逃逸分析。主要是判断变量的作用域是否存在于其他内存栈或者线程中,当一个对象的指针被多个方法或线程引用时,我们称这个指针发生了逃逸。其用来分析这种逃逸现象的方法,就称之为逃逸分析。跟静态代码分析技术中的指针分析和外形分析类似。

  • 标量替换(Scalar Replacement):主要是指使用标量替换聚合量(Java 中的对象实例),把一个对象进行分解成一个个的标量进行逃逸分析,不可选的对象才能进行标量替换。标量主要是指不可分割的量,一般来说主要是基本数据类型和引用类型。

  • 栈上分配(Allocation on Stack): 一般 Java 对象创建出来会在栈上进行内存分配,不是所有的对象都可以实现栈上分配。要想实现栈上分配,需要进行逃逸分析和标量替换。

基本概述


Java 本身是一种面向对象的语言,最显著的特性有两个方面,一是所谓的“书写一次,到处运行”(Write once, run anywhere),能够非常容易地获得跨平台能力;另外就是垃圾收集(GC, Garbage Collection),Java 通过垃圾收集器(Garbage Collector)回收分配内存,大部分情况下,程序员不需要自己操心内存的分配和回收。


我们日常会接触到 JRE(Java Runtime Environment)或者 JDK(Java Development Kit)。 JRE,也就是 Java 运行环境,包含了 JVM 和 Java 类库,以及一些模块等。而 JDK 可以看作是 JRE 的一个超集,提供了更多工具,比如编译器、各种诊断工具等。


对于“Java 是解释执行”这句话,这个说法不太准确。我们开发的 Java 的源代码,首先通过 Javac 编译成为字节码(bytecode),然后,在运行时,通过 Java 虚拟机(JVM)内嵌的解释器将字节码转换成为最终的机器码。但是常见的 JVM,比如我们大多数情况使用的 Oracle JDK 提供的 Hotspot JVM,都提供了 JIT(Just-In-Time)编译器,也就是通常所说的动态编译器,JIT 能够在运行时将热点代码编译成机器码,这种情况下部分热点代码就属于编译执行,而不是解释执行。


众所周知,我们通常把 Java 分为编译期和运行时。这里说的 Java 的编译和 C/C++ 是有着不同的意义的,Javac 的编译,编译 Java 源码生成“.class”文件里面实际是字节码,而不是可以直接执行的机器码。Java 通过字节码和 Java 虚拟机(JVM)这种跨平台的抽象,屏蔽了操作系统和硬件的细节,这也是实现“一次编译,到处执行”的基础。

1.Java 源码分析

Java 源码依据 JDK 提供的 API 来组织有效的代码实体,一般都是通过调用 API 来编织和组成代码的。



对于一段 Java 源代码(Source Code)来说,要想正确被执行,需要先编译通过,最后托管给所承载 JVM,最终才被运行。


Java 是一个主要思想是面向对象的,其中的 Java 的数据类型主要有基本数据类型和包装类类型,其中:


  • 基本数据类型(8 大数据类型,其中 void):byte、short、int、long、float、double、char、boolean、void

  • 包装类类型:Byte、Short、Integer、Long、Float、Double、Character、Boolean、Void


其中,数据类型主要是用来描述对象的基本特征和赋予功能属性的一套语义分析规则。


一般来说 Java 源码的支持,会依据 JDK 提供的 API 来组织有效的代码实体,对于源代码的实现,通常我们都是通过调用 API 来编织和组成代码的。

2.Java 编译机制

Java 编译机制主要可以分为编译前端和编译后端两个阶段,一般来说主要是指将源代码翻译为目标代码的过程,称为编译过程。



编译从一定意义上来说,根本上就是“翻译”,指的计算机能否识别和认识,促成我们与计算机通信的工作机制。


Java 整个编译以及运行的过程相当繁琐,总体来看主要有:词法分析 --> 语法分析 --> 语义分析和中间代码生成 --> 优化 --> 目标代码生成。


具体来看,Java 程序从源文件创建到程序运行要经过两大步骤,其中:


  • 编译前端:Java 文件会由编译器编译成 class 文件(字节码文件),会经过编译原理简单过程的前三步,属于狭义的编译过程,是将源代码翻译为中间代码的过程。

  • 编译后端: 字节码由 java 虚拟机解释运行,解释执行即为目标代码生成并执行。因此,Java 程序既要编译的同时也要经过 JVM 的解释运行。属于广义的编译过程,是将源代码翻译为机器代码的过程。


从详细分析来看,在编译前端的阶段,最重要的一个编译器就是 javac 编译器, 在命令行执行 javac 命令,其实本质是运行了 javac.exe 这个应用。


而对于编译后端的阶段来说,最重要的是 运行期即时编译器(JIT,Just in Time Compiler)和 静态的提前编译器(AOT,Ahead of Time Compiler)。


特别指出,在 Oracle JDK 9 之前, Hotspot JVM 内置了两个不同的 JIT compiler,其中:


  • C1 模式:属于轻量级的 Client 编译器,对应 client 模式,编译时间短,占用内存少,适用于对于启动速度敏感的应用,比如普通 Java GUI 桌面应用。

  • C2 模式:属于重量级的 Server 编译器,对应 server 模式,执行效率高,大量编译优化,它的优化是为长时间运行的服务器端应用设计的,适用于服务器。


但是,我们需要注意的是,默认是采用所谓的分层编译(TieredCompilation)。


在 Oracle JDK 9 之后,除了我们日常最常见的 Java 使用模式,其实还有一种新的编译方式,即所谓的 AOT 编译,直接将字节码编译成机器代码,这样就避免了 JIT 预热等各方面的开销,比如 Oracle JDK 9 就引入了实验性的 AOT 特性,并且增加了新的 jaotc 工具。

3.Java 类加载机制

Java 类加载机制主要分为加载,验证,准备,解析,初始化等 5 个阶段。



当源代码编译完成之后,便是执行过程,其中需要一定的加载机制来帮助我们简化流程,从 Java HotSpot(TM)的执行模式上看,一般主要可以分为三种:


  • 第一种:解析模式(Interpreted Mode)


Marklin:~ marklin$ java -Xint  -versionjava version "1.8.0_291"Java(TM) SE Runtime Environment (build 1.8.0_291-b10)Java HotSpot(TM) 64-Bit Server VM (build 25.291-b10, interpreted mode)Marklin:~ marklin$
复制代码


  • 第二种:编译模式(Compiled Mode)


Marklin:~ marklin$ java -Xcomp  -versionjava version "1.8.0_291"Java(TM) SE Runtime Environment (build 1.8.0_291-b10)Java HotSpot(TM) 64-Bit Server VM (build 25.291-b10, compiled mode)Marklin:~ marklin$
复制代码


  • 第三种: 混合模式(Mixed Mode),主要是指编译模式和解析模式的组合体


Marklin:~ marklin$ java -versionjava version "1.8.0_291"Java(TM) SE Runtime Environment (build 1.8.0_291-b10)Java HotSpot(TM) 64-Bit Server VM (build 25.291-b10, mixed mode)Marklin:~ marklin$
复制代码


不论哪一种模式,只有在具体的使用场景上,Java HotSpot(TM)会依据系统环境自动选择启动参数。



在 Java HotSpot(TM)中,JVM 类加载机制分为五个部分:加载,验证,准备,解析,初始化。其中:


  • 加载:会在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的入口。

  • 验证: 确保 Class 文件的字节流中包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

  • 准备: 正式为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量所使用的内存空间。

  • 解析: 虚拟机将常量池中的符号引用替换为直接引用的过程。

  • 初始化: 前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由 JVM 主导。到了初始阶段,才开始真正执行类中定义的 Java 程序代码。


对于解析阶段,我们需要理解符号引用和直接引用,其中:


  • 符号引用: 符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。符号引用就是 class 文件中主要包括 CONSTANT_Class_info,CONSTANT_Field_info,CONSTANT_Method_info 等类型的常量。

  • 直接引用: 是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。


对于初始化阶段来说,是执行类构造器 client 方法的过程。其方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证子类构造器 client 方法执行之前,父类的类构造器 client 方法已经执行完毕,如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成类构造器 client 方法。


特别需要注意的是,以下几种情况不会执行类初始化:


  • 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。

  • 定义对象数组,不会触发该类的初始化。

  • 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。

  • 通过类名获取 Class 对象,不会触发类的初始化。

  • 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。

  • 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。


在 Java HotSpot(TM)虚拟机中,其加载动作放到 JVM 外部实现,以便让应用程序决定如何获取所需的类,主要提供了 3 种类加载器,其中:



  • 启动类加载器(Bootstrap ClassLoader):负责加载 JAVA_HOME\lib 目录中的,或通过-Xbootclasspath 参数指定路径中的,且被虚拟机认可(按文件名识别,如 rt.jar)的类。

  • 扩展类加载器(Extension ClassLoader):负责加载 JAVA_HOME\lib\ext 目录中的,或通过 java.ext.dirs 系统变量指定路径中的类库。

  • 应用程序类加载器(Application ClassLoader): 负责加载用户路径(classpath)上的类库。 JVM 通过双亲委派模型进行类的加载,当然我们也可以通过继承 java.lang.ClassLoader 实现自定义的类加载器。


当一个类收到了类加载请求,首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候,一般来说是指在它的加载路径下没有找到所需加载的 Class,子类加载器才会尝试自己去加载。



采用双亲委派的一个好处是比如加载位于 rt.jar 包中的类 java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个 Object 对象。


由此可见,使用双亲委派之后,外部类想要替换系统 JDK 的类时,或者篡改其实现时,父类加载器已经加载过的,系统 JDK 子类加载器便不会再次加载,从而一定程度上防止了危险代码的植入。

4.Java 对象组成结构

Java 对象(Object 实例)结构主要包括对象头、对象体和对齐字节三部分。



在一个 Java 对象(Object Instance)中,主要包含对象头(Object Header),对象体(Object Entry),以及对齐字节(Byte Alignment)等内容。


换句话说,一个 JAVA 对象在内存中的存储分布情况,其抽象成存储结构,在 Hotspot 虚拟机中,对象在内存中的存储布局分为 3 块区域,其中:



  • 对象头(Object Header):对象头部信息,主要分为标记信息字段,类对象指针,以及数组长度等三部分信息。

  • 对象体(Object Entry):对象体信息,也叫作实例数据(Instance Data),主要包含对象的实例变量(成员变量),用于成员属性值,包括父类的成员属性值。这部分内存按 4 字节对齐。

  • 对齐字节(Byte Alignment):也叫作填充对齐(Padding),其作用是用来保证 Java 对象所占内存字节数为 8 的倍数 HotSpot VM 的内存管理要求对象起始地址必须是 8 字节的整数倍。


一般来说,对象头本身是填充对齐的参考指标是 8 的倍数,当对象的实例变量数据不是 8 的倍数时,便需要填充数据来保证 8 字节的对齐。其中,对于对象头来说:



  • 标记信息字段(Mark Word): 主要存储自身运行时的数据,例如 GC 标志位、哈希码、锁状态等信息, 用于表示对象的线程锁状态,另外还可以用来配合 GC 存放该对象的 hashCode。

  • 类对象指针(Class Pointer): 用于存放方法区 Class 对象的地址,虚拟机通过这个指针来确定这个对象是哪个类的实例。是指向方法区中 Class 信息的指针,意味着该对象可随时知道自己是哪个 Class 的实例。

  • 数组长度(Array Length): 如果对象是一个 Java 数组,那么此字段必须有,用于记录数组长度的数据;如果对象不是一个 Java 数组,那么此字段不存在,所以这是一个可选字段。根据当前 JVM 的位数来决定,只有当本对象是一个数组对象时才会有这个部分。


其次,对于对象体来说,用于保存对象属性值,是对象的主体部分,占用的内存空间大小取决于对象的属性数量和类型。


而对于对齐字节来说,并不一定是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。当对象实例数据部分没有对齐(8 字节的整数倍)时,就需要通过对齐填充来补全。


特别指出,相对于对象结构中的字段长度来说,其 Mark Word、Class Pointer、Array Length 字段的长度都与 JVM 的位数息息相关。其中:


  • 标记信息字段(Mark Word):字段长度为 JVM 的一个 Word(字)大小,也就是说 32 位 JVM 的 Mark Word 为 32 位,64 位 JVM 的 Mark Word 为 64 位。

  • 类对象指针(Class Pointer):字段长度也为 JVM 的一个 Word(字)大小,即 32 位 JVM 的 Mark Word 为 32 位,64 位 JVM 的 Mark Word 为 64 位。


也就是说,在 32 位 JVM 虚拟机中,Mark Word 和 Class Pointer 这两部分都是 32 位的;在 64 位 JVM 虚拟机中,Mark Word 和 Class Pointer 这两部分都是 64 位的。


对于对象指针而言,如果 JVM 中的对象数量过多,使用 64 位的指针将浪费大量内存,通过简单统计,64 位 JVM 将会比 32 位 JVM 多耗费 50%的内存。


为了节约内存可以使用选项 UseCompressedOops 来开启/关闭指针压缩。


其中,UseCompressedOops 中的 Oop 为 Ordinary Object Pointer(普通对象指针)的缩写。


如果开启 UseCompressedOops 选项,以下类型的指针将从 64 位压缩至 32 位:


  • Class 对象的属性指针(静态变量)

  • Object 对象的属性指针(成员变量)

  • 普通对象数组的元素指针。


当然,也不是所有的指针都会压缩,一些特殊类型的指针不会压缩,比如指向 PermGen(永久代)的 Class 对象指针(JDK 8 中指向元空间的 Class 对象指针)、本地变量、堆栈元素、入参、返回值和 NULL 指针等。


在堆内存小于 32GB 的情况下,64 位虚拟机的 UseCompressedOops 选项是默认开启的,该选项表示开启 Oop 对象的指针压缩会将原来 64 位的 Oop 对象指针压缩为 32 位。其中:


  • 手动开启 Oop 对象指针压缩的 Java 指令为:


   java -XX:+UseCompressedOops tagretClass<目标类>
复制代码


  • 手动关闭 Oop 对象指针压缩的 Java 指令为:


   java -XX:-UseCompressedOops tagretClass<目标类>
复制代码


如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度(Array Length)字段。


这也就意味着,Array Length 字段的长度也随着 JVM 架构的不同而不同:在 32 位 JVM 上,长度为 32 位;在 64 位 JVM 上,长度为 64 位。


需要注意的是,在 64 位 JVM 如果开启了 Oop 对象的指针压缩,Array Length 字段的长度也将由 64 位压缩至 32 位。

5.Java 对象创建流程

Java 对象创建流程主要分为对象实例化,类加载检测,对象内存分配,值初始化,设置对象头,执行初始化等 6 个步骤。



在了解完一个 Java 对象组成结构之后,我们便开始进入 Java 对象创建流程的剖析,掌握其本质有利于我们在实际开发工作中,可参考分析一段 Java 代码的执行后,其在 JVM 中的产生的结果和影响。


从大致工作流程来看,可以分为对象实例化,类加载检测,对象内存分配,值初始化,设置对象头,执行初始化等 6 个步骤。其中:


  • 对象实例化:一般在 Java 领域中指通过 new 关键字来实例化一个对象,在此之前 Java HotSpot(TM) VM 需要进行类加载检测。

  • 类加载检测:进行类加载检测,主要是检测对应的符号引用是否被加载和初始化,最后才决定类是否可以被加载。

  • 对象内存分配: 主要是指当类被加载完成之后,Java HotSpot(TM) VM 会为其分配内存并开辟内存空间,根据情况来确定最终内存分配方案。

  • 值初始化:根据 Java HotSpot(TM) VM 为其分配内存并开辟内存空间,来进行零值初始化。

  • 设置对象头: 完成值初始化之后,设置对象头标记对象实例。

  • 执行初始化: 执行初始化函数,一般是指类构造函数,并为其设置相关属性。


从 Java 对象创建流程的各个环节,具体详细来看,其中:


首先,对于对象实例化来说,主要是看写代码时,用关键词 class 定义一个类其实只是定义了一个类的模板,并没有在内存中实际产生一个类的实例对象,也没有分配内存空间。


而要想在内存中产生一个类的实例对象就需要使用相关方法申请分配内存空间,加上类的构造方法提供申请空间的大小规格,在内存中实际产生一个类的实例,一个类使用此类的构造方法,执行之后就在内存中分配了一个此类的内存空间,有了内存空间就可以向里面存放定义的数据和进行方法的调用。


在 Java 领域中,常见的 Java 对象实例化方式主要有:


  • JDK 提供的 New 关健字:可以调用任意的构造函数(无参的和带参数的)创建对象。

  • Class 的 newInstance()方法: 使用 Class 类的 newInstance 方法创建对象。其中,newInstance 方法调用无参的构造函数创建对象。

  • Constructor 的 newInstance()方法: java.lang.reflect.Constructor 类里也有一个 newInstance 方法可以创建对象,从而可以通过 newInstance 方法调用有参数的和私有的构造函数。

  • 实现 Cloneable 接口并实现其定义的 clone()方法:调用一个对象的 clone 方法,jvm 就会创建一个新的对象,将前面对象的内容全部拷贝进去。用 clone 方法创建对象并不会调用任何构造函数。

  • 反序列化的方式:当我们序列化和反序列化一个对象,jvm 会给我们创建一个单独的对象。在反序列化时,Java HotSpot(TM) VM 创建对象并不会调用任何构造函数。


其次,对于类加载检测来说,当对象实例化之前,其 Java HotSpot(TM) VM 会自行进行检测,主要是:


  • 检测对象实例化的指令是否在类的常量池信息中定位到类的符号引用。

  • 检测符号引用是否被加载和初始化,倘若没有的话便对类进行加载。


然而,对于对象内存分配来说,创建一个对象所需要的内存大小其实类加载完成就已经确定,内存分配主要是在堆中划出一块对象大小的对应内存。具体的分配方式依据堆内存的对齐方式来决定,而堆内存的对齐方式是根据当前程序的 GC 机制来决定的。


再者,对于值初始化来说,这只是依据 Java HotSpot(TM) VM 自动分配的内存对其进行初始化,并设置为零值。


接着,对于设置对象头来说,就是对于每一个进入 Java HotSpot(TM) VM 的对象实例进行对象头信息设置。


最后,对于执行初始化来说,算是 Java HotSpot(TM) VM 真正意义上的执行。

6.Java 对象内存分配机制

Java 对象内存分配机制可以大致分为堆上分配,栈上分配,TLAB 分配,以及年代区分配等方式。



一般来说,在理解 Java 对象内存分配机制之前,我们需要明确理解 Java 领域中的堆(Heap)与栈(Stack)概念,才能更好地掌握和清楚对应到相应的 Java 内存模型上去,主要是大多数时候,我们都是把这两个结合起来讲的,就是常说的“堆栈(Heap-Stack)“模型。其中:


  • 堆(Heap): 用来存放程序动态生成的实例数据,是对象实例化(一般是指 new)之后将其存储,Java HotSpot(TM) VM 会依据对象大小在 Java Heap 中为其开辟对应内存空间大小。

  • 栈(Stack):用来存放基本数据类型和引用数据类型的实例。一般主要是指实例对象的在堆中的首地址,其中每一个线程都有自己的线程栈,被线程独享。


因此,我们可以理解为堆内存和栈内存的概念,相对来说:


  • 堆内存: 用于存储 java 中的对象和数组,当我们 new 一个对象或者创建一个数组的时候,就会在堆内存中开辟一段空间给它,用于存放。堆内存的特点就是:先进先出,后进后出。堆可以动态地分配内存大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的,但缺点是,由于要在运行时动态分配内存,存取速度较慢。由 Java HotSpot(TM) VM 虚拟机的自动垃圾回收器来管理。

  • 栈内存: 主要是用来执行程序用的,栈内存的特点:先进后出,后进先出。存取速度比堆要快,仅次于寄存器,栈数据可以共享,但缺点是,存在栈中的数据大小与生存必须是确定的,缺乏灵活性。栈内存可以称为一级缓存,由垃圾回收器自动回收。


Java 程序在 Java HotSpot(TM) VM 中运行时,从数据在内存区域的分布来看,大致可以分为线程私有区,线程共享区,直接内存等 3 大内存区域。其中 :



  • 线程私有区(Thread Local): 线程私有数据主要是内存区域主要有程序计数器、虚拟机栈、本地方法区,该区域生命周期与线程相同, 依赖用户线程的启动/结束 而 创建/销毁。

  • 线程共享区(Thread Shared): 线程共享区的数据主要是 JAVA 堆、方法区。其区域生命周期伴随虚拟机的启动/关闭而创建/销毁。

  • 直接内存(Direct Memory):非 JVM 运行时数据区的一部分, 但也会被频繁的使用,不受 Java HotSpot(TM) VM 中 GC 控制。比如,在 JDK 1.4 引入的 NIO 提供了基于 Channel 与 Buffer 的 IO 方式, 它可以使用 Native 函数库直接分配堆外内存, 然后使用 DirectByteBuffer 对象作为这块内存的引用进行操作, 这样就避免了在 Java 堆和 Native 堆中来回复制数据, 因此在一些场景中可以显著提高性能。


由此可见,Java 堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java 堆是被所 有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。


对于对象内存分配来说,创建一个对象所需要的内存大小其实类加载完成就已经确定,内存分配主要是在堆中划出一块对象大小的对应内存。具体的分配方式依据堆内存的对齐方式来决定,而堆内存的对齐方式是根据当前程序的 GC 机制来决定的。


对于线程共享区的数据来说,常见的对象在堆内存分配主要有:


  • 指针碰撞: 主要针对堆内存对齐的情况

  • 空闲列表: 主要针对堆内存无法对齐的情况,相互交错

  • CAS 自旋锁和 TLAB 本地内存: 主要针对分配出现并发情况的解决方案


对于线程私有区的数据来说,常见的对象在堆内存分配原则主要有:


  • 尝试栈上分配:满足栈上分配条件,进行栈上分配,否则进行尝试 TLAB 分配。

  • 尝试 TLAB 分配:满足 TLAB 分配条件,进行 TLAB 分配,否则进行尝试老年代分配。

  • 尝试老年代分配:满足老年代分配条件,进行老年代分配,否则尝试新生代分配。

  • 尝试新生代分配:满足新生代分配条件,进行新生代分配。


需要特别注意的是,不论是否能进行分配都是在 Eden 区进行分配的,主要是当出现多个线程同时创建一个对象的时候,TLAB 分配做了优化,Java HotSpot(TM) VM 虚拟机会在 Eden 区为其分配一块共享空间给其线程使用。


Java 对象成员初始化顺序大致顺序为静态代码快/静态变量->非静态代码快/普通变量->一般类构造方法,其中:



按照 Java 程序代码执行的顺序来看,被 static 修饰的变量和代码块肯定是优先初始化的,其次结合继承的思想,父类要比子类优先初始化,最后才是一般构造方法。

写在最后


Java 源码依据 JDK 提供的 API 来组织有效的代码实体,一般都是通过调用 API 来编织和组成代码的。


Java 编译机制主要可以分为编译前端和编译后端两个阶段,一般来说主要是指将源代码翻译为目标代码的过程,称为编译过程。


Java 类加载机制主要分为加载,验证,准备,解析,初始化等 5 个阶段。


Java 对象(Object 实例)结构主要包括对象头、对象体和对齐字节三部分。


Java 对象内存分配机制可以大致分为堆上分配,栈上分配,TLAB 分配,以及年代区分配等方式。


综上所述,一个 Java 对象从创建到被托管给 JVM 时,会经历和完成上面的一系列工作。

发布于: 刚刚阅读数: 5
用户头像

PivotalCloud

关注

⌨️ 90后程序员,后端码客一枚 2019.03.15 加入

阡城有子,幻影成年;北方八度,今出岭南。珠海之边,浮生流年!

评论

发布
暂无评论
如何正确理解Java对象创建过程,我们主要需要注意些什么问题?_PivotalCloud_InfoQ写作社区