• 如果您觉得本站非常有看点,那么赶紧使用Ctrl+D 收藏吧

Java虚拟机

互联网 diligentman 2周前 (10-18) 14次浏览

java的优点:

  • 摆脱了硬件平台束缚,实现了一次编写,到处运行的理想
  • 提供了一个相对安全的内存管理和访问机制,避免了绝大多数的内存泄漏和指针越界
  • 实现了热点代码检测和运行时编译及优化,使得java应用能随着运行时间的增加而获得更高的性能
  • 有一套完善的应用程序接口,还有无数商业机构和开源社区的第三方类库

第一部分

Java技术体系

  • java程设计语言
  • 各种硬件平台的java虚拟机
  • Class文件格式
  • java api类库
  • 第三方java类库

JDK : java程序设计语言,java虚拟机和java api。 是支持java程序开发的最小环境。
JRE : 把java api类库重的 java SE api子集和java虚拟机这两部分统称为jre。 Jre 是支持java程序运营的标准环境

第二部分 自动内存管理

Java虚拟机

2 运行时数据区域

2.1 程序计数器

可以看作是当前线程所执行的字节码行号指示器

如果线程正在执行的是一个java方法,这个计数器记录的是正在执行的虚拟机字节码的指令地址;如果正在执行的是Native方法,这个计数器值为空(Undefined)。此内存区域是唯一一个在java虚拟机规范中没有规定任何OOM情况的区域

2.2虚拟机栈

线程私有的,生命周期与线程相同。
虚拟机栈描述的是java方法执行的内存模型:每个方法在执行时都会创建一个栈帧。用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

局部变量表存放了编译器可知的各种基本类型(boolean、byte、c++har、short、int、float、long、double)对象引用(refrence类型,他不等同于对象本身,可能是一个执行对象起始地址的引用指针,也可能是只想一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)

局部变量表所需要的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的。

2.3本地方法栈

本地方法栈与虚拟机栈发挥的作用类似,不过虚拟机栈为虚拟机执行java方法(也就是字节码)服务,本地方法栈则为虚拟机使用到的Native方法服务。
在虚拟机规范中方法使用的语言、使用的方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现。
本地方法栈与虚拟机一样也会跑出StackOverflowError 和 OutOfMemoryError

2.3java 堆

java堆是被虽有线程共享的一块内存区域。
此区域的唯一目的就是存放对象实例。几乎所有的对象实例都在这里分配内存。

  • 所有的对象实力以及数组都要在堆上分配,但是随着JIT编译器的发展和陶艺分析技术逐渐成熟,栈上分配、标亮替换优化技术将会导致一些微妙的变化,所有的对象都分配在堆上也渐渐变得不那么绝对了。

由于现在的收集器基本都采用粉黛手机算法,所以Java堆还可以细分为:新生代和老年代。
再细致一点:

  • Eden
  • From Survivor
  • To Survivor
  • Old
  • Permanent(对于HotSpot)

如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常

2.5 方法区

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

仅对于Hotspot虚拟机来说,存在永久代这个概念。仅仅是因为HotSpot虚拟机设计团队选择把GC分代手机扩展至方法去,或者使用永久代实现方法区而已。对于其他虚拟机则不存在永久代。

对这一区域的内存回收主要是针对常量池的回收和对类型的卸载。

当方法去无法满足内存分配需求时将抛出OOM异常

2.6 运行时常量池

(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的范本、字段、方法、接口信息等、还有一项是常量常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态特性,Java语言并不要求常量一定只要编译期才能缠身,也就是并非预置入Class文件中常量池的内容才能进入方法区是进入常量池。运行期间可能将新的常量放入池中。

当常量池无法再申请到内存是会抛出OutOfMemoryError

2.7 直接内存

直接内存(Direc++t memory) 并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的区域。但是这部分内存被直接被频繁使用,也可能导致OutOfMemoryError

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

3 HotSpot虚拟机探秘

3.1 对象的创建

虚拟机遇到一条new指令时

  • 检查这个指令的参数是否能在常量池中定位一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,则必须执行相应的类加载过程
  • 虚拟机将为新生对象分配内存。对象所需的大小在类加载完成后便可以确定。
对于频繁创建对象的情况,并发时,并不是线程安全的。可能出现正在给对象A分配内存,还没来得及修改,对象B又同时使用了原来的指针来分配内存。
解决这个问题有两种方案
1、对于分配内存空间的动作同步处理——实际上,虚拟机采用CAS配上失败重试保证更新操作的原子性
2、另一种是把内存分配的动作按照线程划分在不同的空间之中进行,没喝线程在Java堆中预先分配一小块内存,成为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)可以通过 -XX:+/-UseTLAB参数来设定
  • 内存分配完后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)
  • 虚拟机对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象在GC分代年龄等信息。这些信息存放在对象头中。根据虚拟机当前的运行状态不同,如是否使用偏向锁等,对象头会有不同的设置方式
  • 上面的工作都完成后,从虚拟机的视角来看,一个新的对象已经缠身了, 但是从Java程序的角度来看,对象的创建才刚刚开始——<Init>方法还没有执行,所有的字段都还是零。所以,一般来说(由字节码中是否跟随有invokespecial指令所决定),执行new指令之后会接着执行<init>方法,把对象进行初始化。这样才真正可用的对象才完全产生出来。

3.2 对象的内存布局

hotspot虚拟机中,对象在内存中存储的布局可以分为三块区域:

  • 对象头(header)
  • 实例数据(Instanc++e data)
  • 对齐填充(Padding)
对象头

hotspot虚拟机的对象头包括两部分

第一部分用于存储对象自身的运行时数据,如Hash码,GC年龄分代,锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
这部分在32位和64位虚拟机中分别占32bit和64bit(Mark word)

对象头信息是与对象自身定义的数据无关的额外存储成本。
Mark word 被设计成一个非固定的数据结构以便在极小的空间存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

存储内容 标识为 状态
对象哈希码、对象年龄分代 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定
空,不需要记录信息 11 GC标记
偏向线程ID,偏向时间戳、对象分代年龄 01 可偏向
对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

并不是多有的虚拟机实现都必须在对象数据上保留类型指针。也就是说,查找对象元数据信息不一定要经过对象本身。对于Java数组,对象头中还有一块用于记录数组长度的数据,因为虚拟机可以通过普通java对象的元数据信息确定java对象大小,但是从数组的元数据中缺无法确定数组大小。

实例数据部分

也就是程序代码中所定义的各类型的字段内容
这部分的存储顺序会受到虚拟机分配策略参数和字段值java中定义顺序的影响

对齐填充

不是必然存在的,仅仅起着占位符的作用

3.3 对象的访问定位

java需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。
目前主流方式是使用句柄直接指针

  • 如果使用句柄,java会划出一块内存作为句柄池,
  • 如果使用直接指针访问,那么java堆对象的布局就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动时,只会改变句柄中的实例数据指针。
使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销。

3 垃圾收集器与内存分配策略

关于GC需要完成的三件事

  • 哪些内存需要回收
  • 什么时候回收
  • 如何回收

3.2 对象已死

3.2.1引用计数法

循环引用问题

3.2.2 可达性分析

Reac++hability Analysis
通过一系列成为GC Roots的对象作为起始点,从这些节点开始向下搜索。
搜索走过的路径成为引用链。当一个对象的GC roots 没有任何引用链相连接(用图论的话来说,就是GC roots到这个对象不可达),则证明此对象是不可用的。

GC roots包括

  • 虚拟机栈、栈帧中的本地变量表中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法站JNI引用的对象
3.2.3在谈引用

jdk1.2以前,java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表的是一个引用。
对于描述一类对象:当内存空间还够时,能够保留在内存之中,如果内存空间进行垃圾收集后还是非常紧张,则可以抱起这些对象。很多系统的缓存功能都符合这样的应用场景。

java对引用的概念进行了扩充

  • 强引用 就是指代码中普遍存在的,这类引用,只要抢引用还在,垃圾收集器永远不会回收掉被引用的对象
  • 软引用 用来描述一些还有用但是非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出前,将会把这些对象进行二次回收。
  • 弱引用 用来描述非必须的对象,他的强度比软引用更弱。被软引用关联的对象只能生存到下一次垃圾收集发生前。无论内存是否足够,都会被回收掉。
  • 虚引用 是最弱的一种引用关系。一个对象是否有虚引用存在,完全不会影响其生存时间。也无法通过虚引用来取得一个对象的实例。一个对象设置虚引用的唯一目的就是能在这个对象被系统收集时收到一个系统通知。
3.2.4生存还是死亡

即使在可达性分析中不可达的对象,也并非是非死不可的。 他们会处于一个缓刑的状态。真正宣告一个对象的死亡,要经历两次标记过程:

  • 如果对象在可达性分析时发现没有GCroot与其向链,那么将会被第一次标记,并且会进行筛选筛选的条件是次对象有没有必要执行finalize()方法。
  • 如果这个对象被判定为有必要执行finalize()方法,那么这个对象会被放在一个叫做F-Queue的队列之中。finanlize()是一个对象逃脱死亡命运的最后一次机会。稍后GC将会对F-Queue中的对象进行第二次小规模的标记,如果对象要在finanlize()中拯救自己,只需要重新与引用连伤的任何一个对象建立关联即可。

任何一个对象的finalize()都只会被系统自动调用一次。如果系统面临下一次回收,那么finanlize()将不会被执行。

3.2.5回收方法区

永久代的垃圾回收主要回收两部分内容, 废弃常量和无用的类

无用的类

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
  • 加载该类的c++lassLoader已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

3.3 垃圾收集算法

3.3.1 标记清除算法(Mark-Sweep)

最基础的收集算法

  1. 首先标记处所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
  2. 之所以说是最基础的算法,是因为后续的收集算法都是基于这种思路并对其不足进行了改进而得到的。

不足

1.效率问题,标记和清除的两个过程效率都不高
2.清除之后会产生大量不连续的内存碎片,空间碎片太多会导致以后分配较大对象的时候无法找到最狗的连续内存而不得不提前出发垃圾收集动作。

Java虚拟机

3.3.2 复制算法

Java虚拟机

代价是将内存缩小为了原来的一半
3.3.3 标记整理算法

根据老年代的特点,提出了标记整理算法(Mark-Compac++t)算法
Java虚拟机

3.3.4分代收集算法

Generational Collec++tion
知识根据对象存活周期的不同将内存划分为几块。
一般把堆分为新生代和老年代,这样就可以根据各年代的特点采用最适合的收集算法

  • 新生代一般采用复制算法
  • 老年代采用标记清理或者标记整理算法

3.4 Hotspot 的算法实现

3.4.1 枚举根结点

作为GC root的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。

另外可达性分析对执行时间的敏感还体现在GC停顿上

HotSpot使用一组成为OopMap的数据结构来达到这个目的。

3.4.2 安全点

程序执行时并非在所有的地方都能停顿下来开始GC,只有到达安全点才能暂停。
安全点的选定不能太少,以至于让GC等待时间过长,也不能过于频繁导致过分增大运行时的负荷。
所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准。

对于安全点另一个需要考虑的问题是,如何在GC发生时,让所有线程都跑到安全点在停顿下来。

  • 抢占式中断
不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断, 如果发现有线程终端的地方不再安全点上,就恢复线程,让他跑到安全点上
  • 主动式中断
在GC需要中断线程时,不直接对线程操作,仅仅简单的设置一个标志,各个线程执行时,主动去轮询这个标志,发现中断中断标志为真时就终端自己挂起。
3.4.3 安全区域

安全区域是指在一段代码中,引用关系不会发生变化。在这个区域中的任意位置开始GC都是安全的。我们可以把safe region看作是被扩展了的Safepoint
在线程执行到Safe region时,首先标示自己已经进入了Safe region,那样,当在这段时间里jvm要发起GC时,就不用管标示自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根结点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则他就必须等待直到收到可以离开Safe Region的信号为止。

3.5 垃圾收集器

3.5.1 serial
3.5.2 ParNew收集器
3.5.3 Parallel Sc++avenge收集器
3.5.4 Serial Old
3.5.5 Parallel Old
3.5.6 CMS收集器
3.5.7 G1收集器

3.6 内存分配与回收策略

根据自己使用的收集器写一写程序去验证

3.6.1 对象优先在Eden分配

对象在Eden中分配,如果Eden中没有足够空间时,虚拟机发起MinorGc

-XX:SurvivorRation=8 
决定Eden与一个Survivor的空间比例
  • 新生代GC (MinorGC)非常频繁,回收速度较快
  • 老点奶GC(MajorGC)
3.6.2 大对象直接进入老年代
-XX:PremenureSizeThreshold
大于这个值的对象直接分配到老年代中

这样做的目的是为了避免Eden区以及两个Survivor区之间发生大量内存复制

PretenureSizeThreshold 支队Serial和ParNew两款收集器有效。
3.6.3 长期存活的对象将进入老年代

虚拟机给每个对象定义了对象年龄(Age)计数器。
如果对象在Eden出生,并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间,并且对象年龄设为1。
对象年龄增加到一定程度就会被晋升到老年代

-XX:MaxTenuringThreshold可以设置年龄阈值
3.6.4 动态对象年龄判定

为了能更好的适应不同应用程序的内存状况,虚拟机并不永远要求对象的年龄必须达到MaxTenuringThreshold中要求的年龄
如果Survivor空间中相同年龄所有对象的大小综合大于Survivor空间的一般,年龄大于或等于该年龄的对象就可以直接进入老年代

3.6.5 空间分配担保

在发生MinorGC之前 虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。 如果这个条件成立,那么MinorGC可以确保是安全的。
如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。
如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小。
如鬼大雨平寻大小,则会尝试进行一次MinorGC
如果小于,或者HandlePromotionFailure设置不允许冒险,那么这是要改为进行一次Full GC

4 虚拟机性能监控与故常处理

定位系统问题的时候,知识、经验是关键基础,数据是依据。这里说的数据包括:运行日志、异常堆栈、GC日志、线程块照、堆转储块照等

5 调优案例分析与实战

高性能硬件上部署程序

  • 通过64位JDK来使用大内存
  • 使用若干个32位虚拟机建立逻辑集群来利用硬件资源
使用若干个32位虚拟机建立集群来利用硬件资源,具体做法是在一台物理机器上启动多个应用服务进程,,诶个服务器进程分配不同端口,然后在前端搭建一个负载均衡器,以反向代理的方式来分配访问请求。

使用逻辑集群的方式部署程序,可能会遇到一下问题

  • 尽量避免节点竞争全局资源,最典型的就是磁盘竞争
  • 很难最高效率的利用某些资源池,譬如链接池
  • 各节点仍不可避免的收到32位内存的限制。
  • 大量使用本地缓存的应用,在逻辑集群中会造成较大的内存浪费

第三部分 虚拟机执行子系统

Java虚拟机

6 类文件结构

代码编译结果就是从本地机器码转变为字节码

各种不同平台的虚拟机与所有平台都统一使用的程序存储格式–字节码(ByteCode)构成了平台无关性的基石

In the future, we will consider bounded extensions to the Java virtual machine to provide better support for other languages

实现语言无关性的基础仍然是虚拟机和字节码存储格式
java虚拟机不和包括java在内的任何语言班丁,它治愈Class文件这种特定的二进制文件格式所关联。Class文件中伴晗Java虚拟机指令集和符号表以及若干其他辅助信息

6.3 Class类文件结构

《Java虚拟机规范》
任何一个Class文件都对应着唯一一个类或接口的定义信息。但是类或接口并不一定都得定义在文件里(譬如类或接口可以通过类加载器直接生成)

Class文件是一组以8位字节为基础单位的二进制流。
当遇到占用8位字符以上空间的数据项时,会按照高位在前的方式分割成若干个8位字节进行存储

Class文件格式采用一种类似C语言结构题的伪结构来存储数据,这种伪结构只有良种数据类型:无符号数和表

类型 名称 数量
u4 magic 1
u2 minor_version 1
u2 major_version 1
u2 constant_pool_count 1
cp_info constant_pool constant__pool_count-1
u2 access_flags 1
u2 this_class 1
u2 super_class 1
u2 interfaces_count 1
u2 interfaces interfaces_count
u2 fields_count 1
field_info fields 1
u2 methods_count 1
method_info methods methods_count
u2 attributes_count 1
attribute_info attributes attribute_count
6.3.1 魔数与c++lass文件版本

每个class文件的头4个字节成为魔数,唯一的作用是确定这个文件是否能被虚拟机接受的Class文件。

0xCAFEBABE

魔数后面的第5、第6个字节是次版本号, 第7和第8个字节是主版本号

6.3.2 常量池

紧接着主版本号是常量池入口,常量池可以理解为是Class文件的资源仓库

常量池的索引从1开始。设计者将第0项空出来,是为了满足后面某些只想常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义

Class文件中只有常量池的容量计数是从1开始的

长岭池中主要存放两类常量:字面量符号引用
字面量接近于Java语言层面常量概念,文本字符串、声明为final的常量值等
符号饮用属于编译原理方面的概念

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述福

当虚拟机运行时,需要从常量池获得对应符号饮用,再在类创建时或运行时解析、翻译到具体的内存地址中。
常量池中每一项常量都是一个表

6.3.3 访问标志

(Access flags)两个字节代表访问标志
用于识别一些类或者接口层次的访问信息

  • class 是类还是接口
  • 是否定义为public
  • 是否是abstract
  • 是否被声明为final
6.3.4 类索引、父类索引与接口索引集合
  • 索引用于确定这个类的全限定名
  • 父类索引用于确定这个类父类的全限定名
6.3.5 字段表集合

(field_info)用于描述接口或者类中声明的变量

  • 作用域 (public++ private protected)
  • 实例变量还是类变量
  • 可变性
  • 字段数据类型
  • 字段名称

字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段,譬如在内部类中味了保持对外部类的访问性,会自动添加只想外部类实例的字段。

6.3.6 方法表集合
  • 访问标志
  • 名称索引
  • 描述符索引
  • 属性表集合

7 虚拟机类加载机制

类加载机制:虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型

与那些在编译时需要进行连接工作的语言不通,java语言,类型的加载、连接和初始化都是在程序运营期间完成的。这种策略虽然增加一些性能开销,但是为Java应用程序提供了高度的灵活
Java里天生可以动态扩展的语言特性就是以来运营期动态加载和动态链接的特点实现的。
用户可通过Java与定义的和自定义的类加载器,让一个本地的应用程序可以在运行时从网络或者其他地方家在一个二进制流作为程序代码的一部分。

7.2 类加载的时机

类从加载到虚拟机开始到卸载出内存整个生命周期包括:

  • 加载
  • 验证
  • 准备
  • 解析
  • 初始化
  • 使用
  • 卸载

虚拟机规定有且只有5种情况下必须立即对类执行初始化

  • 遇到 new, getstatic++, putstatic或invokestatic这四个字节码指令时。分别对应实例化一个对象、读取或者设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)、调用一个类的静态方法
  • 使用java.lang.reflect包的方法对类进行反射调用的时候
  • 当初始化一个类的时候,如果发现其父类还没初始化,则先初始化其父类
  • 当虚拟机启动时,用户需要制定一个要执行的主类,虚拟机会先初始化这个主类
  • 使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic,REF_invokeStatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出发初始化。

这5种行为被称为主动引用
除此之外,所有的引用类的方法都不会出发初始化,被称为被动引用。

接口与类的初始化的区别在于上述第三条:当一个类在初始化时,要求其把父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都已经完成了初始化,只有在真正使用到父接口时才会初始化。

7.3 类加载过程

加载 验证 准备 解析和初始化

7.3.1 加载
  1. 通过类的全限定名来获取定义此类的二进制字节流
  2. 将这个字节流所代表的精彩存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

对于类加载的其他阶段,一个非数组类的加载阶段(准确的说是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的
对于数组类而言,数组类本身不通过类加载器创建,而是有java虚拟机直接创建的。但是数组类与类加载器仍有密切的关系,因为数组类的元素类型最终要靠类加载器去创建
一个数组类创建过程遵循一下原则

  • 如果数组的组件类型是引用类型,那就递归采用本节中定义的加载过程去加载组件类型。数组c++将在加载该组件类型的类加载器的类名称空间上被标识
  • 如果数组的组件类型不是引用类型(如int[]),java虚拟机会将数组c标记为与引导类加载器关联
  • 数组类的可见性与它的组件类型是一致的,如果组件类型不是引用类型,那数组类的可见性将默认为public

Class对象比较特殊,它虽然是对象,但是存放在方法区里面,这个对象将作为程序访问方法区中这些类型数据的外部接口

7.3.2 验证

为了确保Class文件的字节流中包含的信息负荷当前虚拟机的要求

  • 文件格式验证。基于二进制字节流进行的

基于方法区的存储结构进行的

  • 元数据验证 语义校验
  • 字节码验证 通过数据流和控制流分析,确定程序语义是否符合逻辑,是否合法
  • 符号引用验证。虚拟机将符号引用转化为直接引用
7.3.3 准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段 这些变量所使用的内存都将在方法区进行分配
内存分配的仅包括类变量(static++修饰的变量),而不是实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中

static初始化时为0值,对static变量赋值的putstatic指令时程序被编译后,存放于类构造器<cinit>()方法之中。所以static赋值的动作在初始化才执行

数据类型 零值
int 0
long 0L
short (short)0
char ‘u0000’
byte (byte)0
boolean false
float 0.0f
double 0.0d
reference null

ConstantValue属性所指的值,在准备阶段就会被初始化
static final

7.3.4 解析

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

  • 符号引用(Symbolic Reference) 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可

符号引用与虚拟机内存布局无关,引用的目标不一定已经加载到内存中。符号引用的字面量形式明确定义在Java虚拟机规范中

  • 直接引用(Direct References) 直接引用可以是直接指向目标的指针、相对便宜粱或者一个能够间接定位到目标的句柄。

直接引用是和虚拟机实现的内存布局相关的。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行

7.3.5 初始化

初始化阶段是执行类构造器<cinit>()方法的过程

  • <cinit>()是由编译器自动收集所有的类变量的赋值动作和静态语句快(static{})合并而产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的。

静态语句块只能访问到定义在静态语句块之前的变量。定义在之后的变量,前面的静态语句块可以赋值,但是不能访问。

public class Test{
        static{
            i = 0;  //可以赋值
            System.out.print(i);  //不可引用
        }
        static int i = 1;
}
  • <cinit>()与类构造函数不通,他不需要显示调用父类构造器,虚拟机保证子类<cinit>()执行前,父类的<cinit>()执行完毕
  • 父类的<cinit>()限制性,就意味着父类的定义的静态语句快优先执行
  • <cinit>()对于类或接口并不是必需的,如果一个类中没有静态语句块,也没有对变量的复制操作,那么编译器可以不为这个类生成<cinit>()方法
  • 接口中不能使用静态语句块,但仍有变量初始化的赋值操作,因此接口与类一样都会生成<cinit>()方法,但接口与类不同的是,执行接口的<cinit>()不需要先执行父接口的<cinit>(),只有当父接口中定义的变量使用时,才会初始化父接口
  • 虚拟机会保证一个类的<cinit>()方法在多线程环境中被正确的加锁、同步

7.4 类加载器

通过一个类的全限定名来获取描述此类的二进制字节流

7。4.1 类与类加载器
  • 对于任意一个类,都需要由加载它的类加载器和这个类本身一同确定其在java虚拟机中的唯一性
  • 每一个类加载器都拥有一个独立的类名称空间

比较两个类是否相等,只有来两个类是由同一个类加载器加载的前提下才有意义。

7.4.2 双亲委派

Java虚拟机存在两种不同的类加载器:

  • 启动类加载器(BootStrap ClassLoader) 这个类加载器使用C++实现,是虚拟机自身的一部分
  • 其他类加载器,这些都是Java语言实现的,独立于虚拟机外部,全部继承java.lang.ClassLoader
  • 启动类加载器,负责将存放在<JAVA_HOME>lib目录中的,或者被-Xbootc++lasspath参数所指定的路径中的,并且是被虚拟机是别的类库加载到虚拟机内存中。

启动类加载器无法被java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可

  • 扩展类加载器(Extension ClassLoader) 这个加载器负责加载<JAVA_HOME>libext 目录中的或者被java.ext.dirs系统变量所指定的路径中的所有类库
  • 应用程序类加载器(Application ClassLoader) 这个类加载器时ClassLoader的getSystemClassLoader()方法的返回值,所以一般也称他为系统类加载器。它负责加载用户类路径上所指定的类库

类加载器之间,按照双亲委派模型(Parents Delegation Model) 双亲委派模型要求除了顶层启动类加载器外,其余的类加载器应当都有自己的父类加载器
双亲委派模型的工作过程是:如果一个类加载器收到类加载请求,它首先会委派赴类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都会传送到顶层的启动类加载器。只有当父加载器无法完成这个加载请求时,子加载器才会尝试自己去加载

  • 一个显而易见的好处就是Java类随着他的类加载器一起,具备了一种带有优先级的层次关系。
7.4.3 破坏双亲委派模型

双亲委派模型并不是一个强制性的约束模型

8 虚拟机字节码执行引擎

  • 物理机时建立在处理器、硬件、指令集和操作系统层面上的
  • 虚拟机的执行引擎则是自己实现的,因此可以自行制定指令集与执行引擎的结构体系

在不同的虚拟机实现里面,在执行引擎执行java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即使编译器产生本地代码)

8.2 运行时栈帧结构

栈帧存储了方法的局部变量表,操作数栈、动态链接和方法返回地址等信息
在编译程序代码的时候,栈帧需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存不会收到程序运行时期变量的数据的影响,而仅仅取决于具体的虚拟机实现。

对于互动线程,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stac++k Frame),与这个栈帧相关联的方法,称为当前方法(Curren Method)

8.2.1 局部变量表

是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量

对于64位的数据类型,虚拟机会以高位对其的方式为其分配两个连续的slot空间
局部变量位于县城的堆栈上,无论slot操作是否为院子,都不会引起数据安全问题

方法执行时,虚拟机事使用局部变量表完成参数值到参数变量列表的传递过程。 如果执行的事实例方法,那局部变量表中第0位索引的slot默认事用于传递方法所属对象实例的引用,即 this

slot事可以重用的。

对象是否被回收的根本原因是局部变量表的slot中是否还有关于对象的引用。
不使用的对象应手动赋值为null

局部变量不像类变量存在于准备阶段, 如果一个局部变量定义了,没有赋初始值 是不能使用的。

8.2.2 操作数栈

Operand Stac++k (LIFO)

概念模型中, 两个栈帧作为虚拟机栈的元素,是完全相互独立的。但是在大多数虚拟机里都会做一些优化处理,令两个栈帧出现一部分重叠,让下边的栈帧的部分操作数栈与上边的栈帧部分中和,实现数据共用

8.2.3 动态连接

每个栈帧都包含一个只想运行时常量池中该栈帧所属方法的引用,

8.2.4 方法返回地址

方法对出的过程时机就等同于把当前栈帧出栈,因此退出时可能执行的操作有,恢复上层方法的局部变量表和操作数栈。把返回值压入调用者栈帧的操作数栈中

8.2.5 附加信息

8.3 方法调用

方法调用阶段的文艺任务就是确定被调用方法的版本

Class文件的编译过程不包含传统编译过程的连接步骤,一切方法调用在Class文件里面都是只是符号引用,而不是方法在实际运行时内存布局中的入口地址,这给了Java带来了更强大的动态扩展能力,也是的Java方法调用更复杂,需要在类加载器见,甚至到运行时才能确定目标方法的直接引用

8.3.1 解析

顶用目标在程序代码写好、编译器进行编译时就必须确定下来。 这类方法的调用称为解析(Resolution)

在java语言中符合“编译器可知,运行期不可变”的方法 包括静态方法和私有方法两大类

只要能被invokestatic++和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,他们在类加载的时候就会把符号引用解析为该方法的直接引用

解析是一个静态过程,在编译期就可以完全确定

8.3.2 分派

Dispatch

  1. 静态分派
public class StaticDispatch{
    static abstract class Human{

    }

    static class Man extends Human{

    }

    static class Woman extends Human{

    }
}

Human human = new Man()
Human被称为静态类型(Static Type)
Man 被称为实际类型

虚拟机在重载时时通过参数的静态类型而不是实际类型作为判据的。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派

静态分派典型的应用时方法重载。 静态分派发生在编译阶段。

变长参数重载优先级是最低的。

  1. 动态分派

重写的体现

  • invokevirtual 指令的多态查找
  • invokeVirtual指令 找到操作栈顶第一个元素所指向的对象的实际类型
  1. 单分派和多分派

方法的接受者与方法的参数统称为方法的宗量

  • 单分派是根据一个宗量对目标方法进行选择
  • 多分派是根据多于一个宗量对目标方法进行选择

目前java还是一个静态多分派动态单分派的语言

  1. 虚拟机动态分派的实现

建立虚方法表 使用虚方法表索引来代替元数据查找以提高性能

8.3 动态语言与静态语言

静态类型语言在编译器确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题能在编码的时候就能够发现,有利于稳定性及代码达到更大规模。而动态类型语言在运行期确定类型,这可以为开发人员提供更大的领过行

8.4 基于栈字节码解释执行引擎

基于栈的指令集与基于寄存器的指令集

基于栈的指令集有点事可以治,寄存器由硬件直接提供,程序直接以来这些硬件寄存器,则不可避免的受到硬件的约束。
栈架构指令集还有一些优点 如代码更加紧凑,编译器实现更加简单等

栈架构指令集的主要缺点是执行速度相对来说会慢,

虽然栈架构指令集的代码非常紧凑, 但是完成相同功能需要的指令数量会比寄存器架构多。 另外栈架构在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说内存始终是执行速度的瓶颈。

第四部分程序编译与代码优化

10 早期(编译器)优化

10.3 语法糖
10.3.1 泛型与类型擦除

泛型技术再C#和Java之中的使用方式看似相同,但是实现却又根本的分歧

  • C#的泛型无论再程序源码中、编译后的Il(Intermediate Language)或是运行期的CLR中都是切实存在的, 不同的类型再系统运行期生成,有自己的虚方法表和类型数据。 这种实现称为类型膨胀,基于这种方法实现的反省叫做真实泛型
  • Java中的泛型规则:它只在源码中存在,编译后的字节码文件中就已经替换为原来的原升类型,并在相应的地方插入了强制类型转换。 这种方法称为伪泛型

方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名中,所以返回值不参与重载。
但是在Class文件中,只要描述不是完全一致的方法就可以共存。也就是说,两个方法如果有相同的名称、特征签名但是返回值不同,他们也可以合法的共存于一个Class文件中

Java代码层面的方法特征签名,最重要的任务就是作为独一无二的不可重复的ID,在Java代码层面只包括了方法名称、参数顺序和参数类型
在字节码中的特征签名还包括方法返回值、受查异常表               ```

所谓擦除仅仅是对方法Code属性中的字节码进行擦除,实际上元数据中还保留了泛型信息,这也是我们能通过反射手段取得参数化的根本依据

10.3.2 自动装箱、拆箱循环遍历
10.3.3 条件编译

Java语言的编译方式: 并非一个个的编译Java文件,而是将所有编译但愿的语法树顶级节点输入到待处理列表后进行编译,因此哥哥文件之间能够互相提供符号信息

11 晚期(运行期)优化

11.2.1 解释器与编译器
  • 解释器:省区编译的额时间,立刻执行
  • 编译器: 代码翻译成本地代码,提高执行效率。

当程序运行环境中内存资源限制较大时,使用解释执行,节约内存

-Xint 强制虚拟机运行于解释模式
-Xcomp 运行编译模式
11.2.2 编译对象与触发条件

运行过程中被即时编译器编译的热点代码有两类

  • 多次被调用的方法
  • 被多次执行的循环体

对于第一种情况, 由于是方法调用出发的编译,因此编译器会理所当然的以整个方法作为编译对象,这种编译也是虚拟机中标准的JIT编译方式
对于第二种,由于是循环体所触发的编译动作。编译器仍会以整个方法作为编译对象,这种编译方式因为编译发生在方法执行的过程之中,因此形象的称之为栈上替换(On stac++k Replacement)OSR,即方法栈帧还在栈上就替换了

判断热点代码的依据——热点探测

  • 基于采样的热点探测(Sample based hot spot detection)
  • 基于计数器的热点探测(Counter based hot spot detection)

Hotspot虚拟机中使用第二种,为此准备了两类计数器:方法调用计数器、回边计数器

11.2.3 编译过程

Client Compiler 是一个简单快速的三段式编译器, 主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化

  • 第一个阶段,一个平台独立的前端将字节码构造成一种高级中间态代码表示(High Level Intermediate Representation, HIR),使用静态但分配(Static++ single Assignment),在此之前,编译器会在字节码上完成一部分基础优化,如方法内联,常量传播等
  • 第二阶段,一个平台相关的后端从HIR中产生第几中间代码表示,再次之前,会在HIR上完成一些优化,如控制检查消除,范围检查等
  • 最后,是在平台相关的后端使用线性扫描算法,在LIR上分配寄存器……

Server Compiler是专门面向服务端的典型应用,并为服务端的性能配置特别调整过的编译器。

11.4 Java与C/C++的编译器对比

Java与C/C++的编译器实际上代表了最经典的即时编译器与静态编译器的对比

Java虚拟机的即时编译器与C/C++的静态优化编译器相比可能在下列原因导致输出的本地代码有一些劣势

  • 即时编译器运行占用用户程序的运行时间
  • Java语言是动态的类型安全语言,这就意味着需要由虚拟机来确保程序不会违反语言语义或访问非结构化内存。从实现层面上,虚拟机必须频繁的进行动态检查
  • java语言虽然没有virtual关键字,但是使用虚方法的频率却远大于C/C++,意味着运行时对方法接受者进行多台选择的频率要远远大于C/C++,意味着即时编译器进行一些优化(如内联)时的难度要远远大于C/C++
  • Java时动态扩展语言,运行时加载新的类可能改变程序类型的继承关系,使得很多全局优化无法进行。
  • java语言的对象都是堆上分配的,只有方法的局部变量才能在栈上分配。 C/C++有多种分配方式。如果可以在栈上分配线程私有对象,将减轻内存回收的压力。另外C/C++由于自己进行内存管理,相比垃圾收集机制,运行效率要高。

Java的语言特性时牺牲了部分性能,换去了开发的效率。

第五部分 高效并发

12 Java内存模型与线程

12.2

内存模型:可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的抽象过程

不同架构的物理机器可以拥有不一样的内存模型。

除了增加高速缓存之外,为了是的处理器内部的运算单元尽可能的被利用,处理器会对输入代码进行乱序执行优化。
与处理器的乱序执行优化蕾丝,Java虚拟机的即时编译器也有类似的指令重排序

12.3 Java内存模型

12.3.1 主存与工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则。即虚拟机中将变量存储到内存和从内存取出变量这样的底层细节

变量包括了:实例字段、静态字段和构成数组对象的元素

所有的变量都存在主内存中。 每条线程还有自己的工作内存。线程的工作内存中保存了呗该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作都必须在工作内存中进行,不能直接读写主存变量。

12.3.2 内存间交互操作

定义了如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节

  • lock,锁定:作用于主内存的变量,把一个变量表识为一条线程独占的状态
  • unlock,解锁:作用于主内存变量,把一个处于锁定状态的变量释放出来
  • read,读取:作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存
  • load,载入:作用于工作内存内的变量,把read操作从主内存得到的变量值放入工作内存的变量副本中
  • use,使用:作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎
  • assign,赋值:作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
  • store,存储,作用于工作内存变量,它把工作内存中的变脸该值传送到主内存中
  • write,写入:作用于主内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中

此外,Java内存模型还规定了在执行上述8种基本操作时必须满足

  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃它最近assign的早错
  • 不允许一个线程无原因的把数据从工作内存同步到主内存
  • 一个新的变量只能在主内存中诞生
  • 一个变量在同一时刻只允许一条线程对其lock
  • 如果对一个变量执行lock,那会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或者assign
  • 如果一个变量没有事先呗loc++k,那就不逊于执行unlock
  • 对一个变量unlock前,必须先把此变量同步回主内存
12.3.3 volatile
  • 保证此变量对所有线程时可见的
  • volatile禁止指令重排序优化
12.3.4 long/double 64位数据类型
12.3.5 原子性、可见性和有序性
  • 原子性, 内存模型直接来保证原子性变量、基本类型的访问读写就是具备原子性的,(另外就是 long、double的非原子性协议)

java内存模型提供了loc++k和unlock来满足更大范围的原子性保证,尽管没有直接给用户使用,却提供了高层次的monitorenter和monitorexit来隐式使用这两个操作。这两个字节码反映到Java代码中就是synchronized关键字

  • 可见性, 指当一个线程修改了共享变量的值,其他线程能够立刻得知这个修改。

除了 volatile之外还有sunchronized和final能够保证可见性

  • 有序性,
12.3.6 Happens before

Java内存模型下一些天然的先行发生关系,这些先行发生关系无需任何同步协助就已经存在,如果两个操作无法从这些关系中推导出来,他们就没有顺序性保障,虚拟机就可以对他们进行任意重排序

  • 程序次序规则(Program Order Rule):在一个线程内,按照程序代码的。(控制流顺序而不是代码顺序)
  • 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于,后面对同一个所的lock操作
  • volatile变量规则:一个对volatile的鞋操作咸鱼后面对着变量的读操作
  • 线程启动规则
  • 线程终止规则
  • 线程终端规则
  • 对象中介规则
  • 传递性

12.4 Java与线程

12.4.1 线程的实现
  • 使用内核线程实现(KLT。 kernel level thread)

程序一般不会直接使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(LWP,Light weight proc++ess),轻量级进城就是我们通常意义上所讲的线程。 轻量级进程与内核线程的比例1:1

由于是基于内核线程实现的,所以各种线程操作,如创建、析构、同步都需要进行系统调用。而系统调用的代价相对较高,需要在用户态和内核台来回切换,其次,每个轻量级进程都需要一个内核线程的支持,因此轻量级进程要消耗一定的内核资源
  • 使用用户线程实现

    广义上的 一个线程只要不是内核线程 就可以认为是用户线程。
    狭义上,使之完全建立在用户空间上的线程。系统内核不感知线程存在。用户线程的建立、同步、销毁和调度完全再用户态中完成,不需要再内核的帮助。

    由于不需要切换到内核态,因此操作可以是非常快,消耗低。但是由于没有内核志愿,所有的处理都需要用户程序自己处理。

    • 使用用户线程加轻量级线程
    • Java线程的实现

操作系统支持怎样的线程模型,在很大程度上决定了Java虚拟机的线程是怎样映射的,这点再不同的平台上没有办法达成一致。 线程模型只是对线程的并发规模和操作成本产生影响,对Java程序的编码和运行过程来说,这些差异都是透明的。

12.4.2 Java线程调度
  • 协同式线程调度
  • 抢占式线程调度
12.4.3 状态转换
  • 新建
  • 运行
  • 无限期等待
  • 限期等待
  • 阻塞
  • 结束

13 线程安全与锁优化

线程安全:当多个线程访问一个对象时,如果不用考虑这些线程再运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他协同的操作,调用这个对象的行为都可以获得正确的结果,那这个对象时线程安全的。

13.2.1 Java语言中的线程安全

1.不可变

2.绝对线程安全

3.相对线程安全

4.线程兼容

  1. 线程对立
13.2.2线程安全的实现方法

1.互斥同步

  • sync++hronized/reentrantlock
相比synchronized,ReentrantLock新增的功能包括,等待可中断,可实现公平锁,以及锁可以绑定多个条件

2.非阻塞同步
基于冲突检测的乐观并发策略

  • 乐观并发策略需要硬件指令集的发展才可以进行
  • CAS: c++ompareAndSwap
cas指令需要有三个操作数,分别是内存位置,旧的预期值,新值

对于CAS的ABA问题
J.U.C为了解决这个问题提供了一个带有标记的引用类,它可以通过控制变量值的版本来保证CAS的正确性。不过大部分情况下ABS问题不会影响程序并发的正确性。

3.无同步方案

可重入代码:无状态依赖
线程本地存储

13.3 锁优化

13.3.1 自旋锁与自适应自旋锁

为了让线程等待,只需要让线程执行一个忙循环,这项技术即所谓的自旋锁
如果被占用的十佳很短,自旋锁的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。

13.3.2 锁消除

如果判断一段代码,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把他们当作栈上的数据对待,认为他们是线程私有的,同步加锁可以去掉

13.3.3 锁粗化

如果探测到有一串零碎的操作都对同一个对象枷锁,将会把枷锁同步的范围扩展到整个操作序列的外部,

13.3.4 轻量级锁

轻量级锁并不是用来代替重量级锁,它的本意是在没有多线程竞争的前提下减少传统的重量级锁使用操作系统互斥量产生的性能消耗

理解轻量级锁,必须从HotSpot虚拟机对象头的内存布局介绍。
HotSpot虚拟机的对象头分为两部分,第一部分用于存储对象自身的运行时数据,如哈希码,GC分代年龄等。另一部分用于存储只想方法区的对象类型数据指针。

13.3.5 偏向锁

偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操锁区消除同步使用的互斥量。偏向锁就是在无竞争的状况下,把整个同步都消除掉。


程序员灯塔
转载请注明原文链接:https://www.wangt.cc/2020/10/java%e8%99%9a%e6%8b%9f%e6%9c%ba/
喜欢 (0)