资讯

精准传达 • 有效沟通

从品牌网站建设到网络营销策划,从策略到执行的一站式服务

Java 并发编程解析 | 如何正确理解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  -version
java 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  -version
java 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 -version
java 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时,会经历和完成上面的一系列工作。

版权声明:本文为博主原创文章,遵循相关版权协议,如若转载或者分享请附上原文出处链接和链接来源。


新闻名称:Java 并发编程解析 | 如何正确理解Java对象创建过程,我们主要需要注意些什么问题?
浏览地址:http://www.cdkjz.cn/article/dscgepp.html
多年建站经验

多一份参考,总有益处

联系快上网,免费获得专属《策划方案》及报价

咨询相关问题或预约面谈,可以通过以下方式与我们联系

大客户专线   成都:13518219792   座机:028-86922220