苍穹之边,浩瀚之挚,眰恦之美; 悟心悟性,善始善终,惟善惟道! —— 朝槿《朝槿兮年说》
写在开头
从接触 Java 开发到现在,大家对 Java 最直观的印象是什么呢?是它宣传的 “Write once, run anywhere”,还是目前看已经有些过于形式主义的语法呢?有没有静下心来仔细想过,对于 Java 到底了解到什么程度?
自从业以来,对于Java的那些纷纷扰扰的问题,我们或多或少都有些道不明,说不清的情绪,一直心有余悸,甚至困惑着我们的,可曾入梦。
是不是有着,不论查阅了多少遍的资料,以及翻阅了多少技术大咖的书籍,也未能解开心里那由来已久的疑惑,就像一个个未解之谜一般萦绕心扉,惶惶不可终日?
我一直都在问自己,一段Java代码的中类,从编写到编译,经过一系列的步骤加载到JVM,再到运行的过程,它究竟是如何运作和流转的,其机制是什么?我们看到的结果究竟是如何呈现出来的,这其中发生了什么?
虽然,从学习Java之初,我们都会了解和记忆,以及在后来大家在提及的时候,大多数都是一句“我们应该都不陌生”,甚至“我相信大家都了然于心”之类话“蜻蜓点水”般轻描淡写。
但是,如果真的要问一问的话,能详细说道一二的,想必都会以“夏虫不可语冰“的悲剧上演了吧!作为一名Java Develioer来说,正确了解和掌握这些原理和机制,早已经不是什么”不能说的秘密“。
带着这些问题,今日我们便来扒一扒一个Java对象中的那些枝末细节,一个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的数据类型主要有基本数据类型和包装类类型,其中:
其中,数据类型主要是用来描述对象的基本特征和赋予功能属性的一套语义分析规则。
一般来说Java源码的支持,会依据JDK提供的API来组织有效的代码实体,对于源代码的实现,通常我们都是通过调用API来编织和组成代码的。
2.Java编译机制
Java编译机制主要可以分为编译前端和编译后端两个阶段,一般来说主要是指将源代码翻译为目标代码的过程,称为编译过程。
编译从一定意义上来说,根本上就是“翻译”,指的计算机能否识别和认识,促成我们与计算机通信的工作机制。
Java整个编译以及运行的过程相当繁琐,总体来看主要有:词法分析 --> 语法分析 --> 语义分析和中间代码生成 --> 优化 --> 目标代码生成。
具体来看,Java程序从源文件创建到程序运行要经过两大步骤,其中:
从详细分析来看,在编译前端的阶段,最重要的一个编译器就是javac 编译器, 在命令行执行javac命令,其实本质是运行了javac.exe这个应用。
而对于编译后端的阶段来说,最重要的是 运行期即时编译器(JIT,Just in Time Compiler)和 静态的提前编译器(AOT,Ahead of Time Compiler)。
特别指出,在Oracle JDK 9之前, Hotspot JVM 内置了两个不同的 JIT compiler,其中:
但是,我们需要注意的是,默认是采用所谓的分层编译(TieredCompilation)。
在Oracle JDK 9之后,除了我们日常最常见的 Java 使用模式,其实还有一种新的编译方式,即所谓的 AOT编译,直接将字节码编译成机器代码,这样就避免了 JIT 预热等各方面的开销,比如 Oracle JDK 9 就引入了实验性的 AOT 特性,并且增加了新的 jaotc 工具。
3.Java类加载机制
Java类加载机制主要分为加载,验证,准备,解析,初始化等5个阶段。
当源代码编译完成之后,便是执行过程,其中需要一定的加载机制来帮助我们简化流程,从Java HotSpot(TM)的执行模式上看,一般主要可以分为三种:
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$
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$
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类加载机制分为五个部分:加载,验证,准备,解析,初始化。其中:
对于解析阶段,我们需要理解符号引用和直接引用,其中:
对于初始化阶段来说,是执行类构造器 client方法的过程。其方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证子类构造器 client方法执行之前,父类的类构造器 client方法已经执行完毕,如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成类构造器 client方法。
特别需要注意的是,以下几种情况不会执行类初始化:
在Java HotSpot(TM)虚拟机中,其加载动作放到JVM外部实现,以便让应用程序决定如何获取所需的类,主要提供了3种类加载器,其中:
当一个类收到了类加载请求,首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候,一般来说是指在它的加载路径下没有找到所需加载的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 块区域,其中:
一般来说,对象头本身是填充对齐的参考指标是8的倍数,当对象的实例变量数据不是8的倍数时,便需要填充数据来保证8字节的对齐。其中,对于对象头来说:
其次,对于对象体来说,用于保存对象属性值,是对象的主体部分,占用的内存空间大小取决于对象的属性数量和类型。
而对于对齐字节来说,并不一定是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。当对象实例数据部分没有对齐(8字节的整数倍)时,就需要通过对齐填充来补全。
特别指出,相对于对象结构中的字段长度来说,其Mark Word、Class Pointer、Array Length字段的长度都与JVM的位数息息相关。其中:
也就是说,在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位:
当然,也不是所有的指针都会压缩,一些特殊类型的指针不会压缩,比如指向PermGen(永久代)的Class对象指针(JDK 8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。
在堆内存小于32GB的情况下,64位虚拟机的UseCompressedOops选项是默认开启的,该选项表示开启Oop对象的指针压缩会将原来64位的Oop对象指针压缩为32位。其中:
如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度(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对象创建流程的各个环节,具体详细来看,其中:
首先,对于对象实例化来说,主要是看写代码时,用关键词class定义一个类其实只是定义了一个类的模板,并没有在内存中实际产生一个类的实例对象,也没有分配内存空间。
而要想在内存中产生一个类的实例对象就需要使用相关方法申请分配内存空间,加上类的构造方法提供申请空间的大小规格,在内存中实际产生一个类的实例,一个类使用此类的构造方法,执行之后就在内存中分配了一个此类的内存空间,有了内存空间就可以向里面存放定义的数据和进行方法的调用。
在Java领域中,常见的Java对象实例化方式主要有:
其次,对于类加载检测来说,当对象实例化之前,其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)“模型。其中:
因此,我们可以理解为堆内存和栈内存的概念,相对来说:
Java程序在Java HotSpot(TM) VM中运行时,从数据在内存区域的分布来看,大致可以分为线程私有区,线程共享区,直接内存等3大内存区域。其中 :
由此可见,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所 有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。
对于对象内存分配来说,创建一个对象所需要的内存大小其实类加载完成就已经确定,内存分配主要是在堆中划出一块对象大小的对应内存。具体的分配方式依据堆内存的对齐方式来决定,而堆内存的对齐方式是根据当前程序的GC机制来决定的。
对于线程共享区的数据来说,常见的对象在堆内存分配主要有:
对于线程私有区的数据来说,常见的对象在堆内存分配原则主要有:
需要特别注意的是,不论是否能进行分配都是在Eden区进行分配的,主要是当出现多个线程同时创建一个对象的时候,TLAB分配做了优化,Java HotSpot(TM) VM虚拟机会在Eden区为其分配一块共享空间给其线程使用。
Java对象成员初始化顺序大致顺序为静态代码快/静态变量->非静态代码快/普通变量->一般类构造方法,其中:
按照Java程序代码执行的顺序来看,被static修饰的变量和代码块肯定是优先初始化的,其次结合继承的思想,父类要比子类优先初始化,最后才是一般构造方法。
写在最后
Java源码依据JDK提供的API来组织有效的代码实体,一般都是通过调用API来编织和组成代码的。
Java编译机制主要可以分为编译前端和编译后端两个阶段,一般来说主要是指将源代码翻译为目标代码的过程,称为编译过程。
Java类加载机制主要分为加载,验证,准备,解析,初始化等5个阶段。
Java对象(Object实例)结构主要包括对象头、对象体和对齐字节三部分。
Java对象内存分配机制可以大致分为堆上分配,栈上分配,TLAB分配,以及年代区分配等方式。
综上所述,一个Java对象从创建到被托管给JVM时,会经历和完成上面的一系列工作。
Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved