Java-虚拟机

本篇文章是对https://github.com/CyC2018/CS-Notes的学习

目录

1 什么是java虚拟机?

2 运行时数据区域

程序计数器

Java虚拟机栈

本地方法栈

元数据区

直接内存

3 垃圾收集

回收哪些内存空间?

判断一个对象是否可被回收?

引用类型

垃圾收集算法

垃圾收集器

4 内存分配与回收策略

Minor GC和Full GC

内存分配策略

Full GC的触发条件

5 类加载机制

类的生命周期

类的加载过程

类的初始化时机

类和类加载器

类加载器分类

双亲委派模型

自定义类加载器


1 什么是java虚拟机?

首先我们知道java语言有一个很重要的特性就是“跨平台”可以做到“一次编译,到处运行”的效果。怎样才能有这样的特性呢,主要就是依靠的java虚拟机。

虚拟机是一种抽象化的计算机,通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java虚拟机有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java虚拟机屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。----百度百科

理解:当我们编写好一个java程序之后如test.java。然后将其编译为一个字节码文件test.class。在java虚拟机上运行这个字节码文件,java虚拟机就可以把字节码文件解释成具体平台上的机器指令执行,而实现了java的跨平台特性。

java程序的运行过程如下:

有两种方式,如果是在自己的平台上直接存在字节码文件,java虚拟机会直接加载这个字节码进行解析并执行main方法运行程序。

如果要将程序在其它平台运行,最好打包成jar包,在cmd通过执行java -jar XXX.jar指令后,java虚拟机会找到对应的字节码进行解析运行。

2 运行时数据区域

此部分参考https://blog.csdn.net/bruce128/article/details/79357870

è¿éåå¾çæè¿°

  • 程序计数器

程序计数器是一块较小的内存空间,属于“线程私有”的内存。他可以看作是当前线程所执行的字节码的行号指示器。在进行线程恢复、分支、循环等功能都需要这个程序计数器来完成。如果当前线程执行的是native修饰的方法,其值为空。此内存区域是唯一一个在java虚拟机规范中没有定义任何OutOfMemoryError的区域。

了解一下native?

被native修饰的方法叫做本地方法,本地方法和其它方法不一样。本地方法意味着和平台有关,因此使用了native的程序可以执行都不太高。另外native方法在JVM中运行时数据区也和其它方法不一样,它有专门的本地方法栈。native方法主要用于加载文件和动态链接库,由于java语言无法访问操作系统底层信息(比如:底层硬件设备等),这时候就需要借助C语言来完成了。被native修饰的方法可以被C语言重写。  简单地讲,一个Native Method就是一个java调用非java代码的接口。一个Native Method是这样一个java的方法:该方法的实现由非java语言实现,比如C。

  • Java虚拟机栈

stack

Java虚拟机栈也属于线程私有的,每个线程对应一个Java虚拟机栈,其声明周期与线程相同。每个java方法在被调用的时候都会创建一个栈帧,并入栈。一旦完成调用,则出战。所有栈帧都出栈后,线程也就完成了使命。在每一个方法栈帧中,都存储了局部变量表、操作数栈、动态链接、方法出口信息。局部变量表存放了编译期间可以知道的八大原始类型、对象的引用(指向对象的一个地址)、returnAddress类型(指向了一条字节码的地址)。操作数栈可以理解为java虚拟机栈中的一个用于计算的临时数据存储区域。

在java虚拟机规范中,对这个数据区域规定了两种异常状况:

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverFlowError。

栈如果动态扩展时无法申请到足够的内存,会抛出OutOfMemoryError。

  • 本地方法栈

本地方法栈与虚拟机栈所发挥的作用非常类似,它们之间的区别不过是虚拟机栈为虚拟机执行java方法(也就是字节码)服务,而本地方法栈为虚拟机使用到的native方法服务。本地方法一般是用其它语言编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。

Java堆是被多有线程共享的一块区域,用来存放对象实例。Java堆是内存占用最大、管理最复杂的一个区域:几乎所有的对象实例及数组都在堆上进行分配。1.7之后,字符串常量池从永久代中剥离出来,存放在堆中。堆有自己进一步的内存划分,按照GC分代收集角度的划分如下:

heap

  • 1 堆空间内存分配

老年代:三分之二的堆空间

年轻代:三分之一的堆空间

               eden区:8/10的年轻代空间

               survivor0:1/10的年轻代空间

               survivor01:1/10的年轻代空间

命令行上执行如下命令,查看所有默认的jvm参数

java -XX:+PrintFlagsFinal -version

输出结果中包含:

[Global flags]
    uintx InitialSurvivorRatio                      = 8                                   {product}
    uintx NewRatio                                  = 2                                   {product}
    ... ...
java version "1.8.0_91"
Java(TM) SE Runtime Environment (build 1.8.0_91-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.91-b14, mixed mode)
参数作用
-XX:InitialSurvivorRatio新生代Eden/Survivor空间的初始比例
-XX:NewratioOld区 和 Yong区 的内存比例
  • 2 字符串常量池

jdk1.7之后就开始”去永久代“的工作了。1.7把字符串常量池从永久代中剥离出来,存放在堆空间中。

  • 元数据区

元数据区取代了1.7版本以前的永久代。元数据区和永久代的本质上都是方法区的实现。方法区(元数据区)存放虚拟机加载的类信息。静态变量,常量等数据存放在堆中。在1.7之前,类信息,静态变量,常量,字符串常量池都位于方法区。

  • 直接内存

直接分配堆外空间。jdk引入了NIO,NIO是一种基于通道和缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存。然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样能在一些场景中显著提高性能,因为避免了在Java堆和native堆中来回复制数据。可能存在OutOfMemoryError异常。

3 垃圾收集

  • 回收哪些内存空间?

对于程序计数器,虚拟机栈和本地方法栈属于线程私有的,随着线程的结束会消失,所以不需要进行垃圾回收。垃圾回收主要指的是死亡的对象占据的堆内存空间。垃圾回收的主要区域为堆内存。

  • 判断一个对象是否可被回收?

  • 引用计数法

引用+1,失效减1,无法解决循环引用。具体的为给对象添加一个引用计数器,每当有一个地方引用这个对象,计数器值就加 1;当引用失效时,计数器的值就减1.当计数器的值为0的时候可以被回收。

循环引用是指,对象a引用对象b,对象b引用对象a,当把a和b的引用去掉以后,由于a和b还互相引用着对方,所以计数器的值不为0,不能被回收。因此JVM不是用这种方法来判断对象是否可回收。

  • 可达性分析算法

以GC Roots为起点,从这些节点开始向下搜索,搜索所走过的路程成为引用链,当一个对象到GC Roots没有任何引用链的时候,说明对象是不可回收的。

简单点说,就是以GC Roots为起点进行搜索,不可达的对象都是可以被回收的。

java虚拟机一般使用可达性分析算法来判断一个对象是否可被回收,GC Roots一般包含以下内容。

  1. 虚拟机栈中(栈帧中的本地变量表)引用的对象。
  2. 方法区中的静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中JN1(即一般说的native)引用的对象
  • 回收方法区

因为方法区只要存放永久代对象,而永久带对象的回收率比新生代低很多,所以在方法去上进行回收性价比不高。主要是对常量池的回收和对类的卸载。

对常量池的回收和堆中对象的回收相似,判断是否还存在对常量的引用,判断其是否可被回收。

为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。

类的卸载条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载。

  1. 该类的所有实例都已经被回收,此时堆中不存放该类的任何实例
  2. 加载该类的classloader已经被回收
  3. 该类对应的class对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法
  • finalize()

类似 C++ 的析构函数,用于关闭外部资源。但是 try-finally 等方式可以做得更好,并且该方法运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。

当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会再调用该方法。

  • 引用类型

无论是通过引用计数算法判断对象的引用数量,还是通过可达性算法判断对象的引用链是否可达,判断对象是否存活都与引用有关。Java提供了四种强度不同的引用类型。

  • 强引用

使用new一个新对象的方式创建的引用,只要强引用还存在,垃圾回收期永远不会回收掉被引用的对象。

Object obj = new Object();
  • 软引用

被软引用关联的对象只有在内存不够的情况下才会被回收,使用SoftReference来创建软引用

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  // 使对象只被软引用关联
  • 弱引用

被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。使用 WeakReference 类来创建弱引用

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
  • 虚引用

又成为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。

为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。使用 PhantomReference 来创建虚引用。

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;
  • 垃圾收集算法

  • 标记-清除

两个过程:1。标记,标记出需要回收的对象。2.清除,在标记完成后同一回收所有被标记的对象。

缺点:1.标记和清除的过程效率都不高。2.会产生大量不连续的内存碎片,无法给大对象分配内存。此时不得不触发垃圾回收动作。

  • 标记-整理

让所有或者的对象都向一段移动,然后直接清理掉端点意外的内存。

优点:不会长生内存碎片

缺点:移动大量对象,处理效率较低。

  • 复制

将内存划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活的对象复制到另外一块上,然后把已经使用过的这块内存呢空间一次清理掉。

缺点是:只使用了内存的一半

现在的商业虚拟机都使用这种算法来回收新生代。但是不是划分为大小相等的两块,而是一块较大的Eden空间和两块较小的survivor空间,每次使用Eden和其中的一块survivor。在回收时,将Eden和survivor中还存活的对象全都复制到另一块survivor上,最后清理Eden和使用过的那一块survivor。

Hotpot虚拟机的Eden和survivor的默认比例为8:1,保证了内存的利用率达到了90%。如果每次回收有多于10%的对象存活,那么一块survivor就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。

 

  • 分代收集

现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。一般将堆分为新生代和老年代。

新生代:复制算法

老年代:标记-清除 或者 标记-整理 算法。

了解一下新生代和老年代?

堆被划分为新生代和老年代,新生代又被划分为eden区和s0、s1区。

新创建的对象存放在eden区,当eden区满时,执行gc,将eden中的存活对象存放到s0区中。当eden区再一次满时,执行gc,同时执行复制算法,将eden区和s0区存活对象存放到s1区中,同时交换s0和s1的角色。当对象复制达到16次时,对象被存放在老年代。

为什么新生代需要两个survivor区?

如果只有一个survivor区的话,每次执行gc后,存活的对象都被放在这个survivor区中,会导致内存使用的不连续,即产生碎片。而使用两个survivor区的话,每次执行gc后,eden区和s0区的存活对象就被存放在s1区中,避免了碎片的产生

  • 垃圾收集器

HotSpot虚拟机中有7种垃圾收集器。

连线表示垃圾收集器可以配合使用,虚拟机所处的区域表示它是属于新生代收集器还是老年代收集器。

单线程与多线程:单线程指垃圾收集器只使用一个线程,多线程指垃圾收集器使用多个线程。

串行与并行:串行指的是垃圾收集器与用户程序交替执行,这意味着执行垃圾收集的时候需要停顿用户程序;并行指的是垃圾收集器和用户程序同时执行。除了CMS和G1之外,其它垃圾收集器都是以串行的方式执行。

  • Serial(串行)收集器

新生代:复制算法

Serial翻译为串行,只会使用一个线程进行垃圾收集工作。

优点:简单高效,在单个cpu环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率。

缺点:在进行垃圾收集的时候会暂停掉其它的线程

它是client场景下的默认新生代收集器,因为在该场景下内存一般来说不会很大。它收集一两百兆垃圾的停顿时间可以控制在一百多毫秒以内,只要不是太繁琐,这点停顿还是可以接受的。

  • ParNew收集器

新生代:复制算法

它是serial的多线程版本,串行。

它是server场景下默认的新生代收集器,除了性能原因以外,主要是因为ParNew是除了Seria收集器,只有它能与CMS配合使用。

  • Parallel Scavenge 收集器

新生代:复制算法。

Parallel Scavenge是一款多线程收集器

其它收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,因此它被称为”吞吐量优先”收集器。吞吐量=CPU运行用户代码时间/(CPU运行用户代码时间+垃圾收集时间)

缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收的频繁,导致吞吐量降低

可以通过一个开关参数打开GC自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小,Eden和Survivor d的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大吞吐量。

  • Serial Old 收集器

老年代:标记-整理

是 Serial 收集器的老年代版本,也是给 Client 场景下的虚拟机使用。如果用在 Server 场景下,它有两大用途:

  1. 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
  2. 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
  • Parallel Old 收集器

老年代:标记-整理

Parallel Scavenge的老年代版本

在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

  • CMS收集器

CMS(Concurrent Mark Sweep)标记-清除算法。老年代。是一种获取最短回收停顿时间为目标的收集器。

主要使用场景为互联网站或者B/S系统的服务端上,因为想要获取最快的响应速度。

分为4个阶段:

初始标记:仅仅是标记一下GC Roots能直接关联到的对象,速度很快,需要停顿

并发标记:进行GC Roots Tracing的过程,它在整个回收过程中耗时最长不需要停顿

重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。

并发清除:不需要停顿。

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

具有以下缺点:

吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。

无法处理浮动垃圾(对CPU资源非常敏感):可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。

标记 - 清除算法导致的空间碎片:往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

  • G1收集器

G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。

堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。

G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。

通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。

每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

初始标记:

并发标记:

最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。

筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

具备如下特点:

空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。

可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

4 内存分配与回收策略

  • Minor GC和Full GC

Minor GC:回收新生代,因为新生代存活时间短,因此Minor GC会频繁执行,执行的速度一般也会比较快。

Full GC:回收老年代和新生代,老年代对象存活时间长,因此Full GC很少执行,执行速度会比Minor GC慢很多。

  • 内存分配策略

  1. 对象优先在eden分配:当eden的空间不够的时候,发起MInor GC。
  2. 大对象直接进入老年代:所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串及数组。经常出现大对象会提前触发垃圾收集以获取足够大的连续空间分配给大对象。-XX:PretenureSizeThreshold,大于此阈值的对象直接在老年代分配,避免在Eden和Survivor之间的大量复制。
  3. 长期存活的对象进入老年代:对对象定义年龄计数器,对象在Eden出生并且经过Minor GC依然存活,将移动到Survivor中,年龄就增加一岁,增加到一定年龄则移动到老年代中(默认为15岁)。可以通过-XX:MaxTenuringThreshold来定义年龄的阈值
  4. 动态对象年龄判定:虚拟机不总是要求年龄必须到达阈值才能进入老年代,当Survivor中相同年龄所有对象的总和大于Survivor空间的一半,则年龄大于等于该年龄的对象可以直接进入老年代,无需等到设定的年龄阈值。
  5. 空间分配担保:在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代对象的总空间,如果大于,那么Minor GC是安全的。如果不成立的话虚拟机会检查HandlePromotionFailure 的值查看是否允许担保失败,如果允许那么就继续检查虚拟机老年代中最大可用连续空间是否大于历次晋升老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC;如果小于,或者HandlePromotionFailure 的值不允许,那么就要进行一次Full GC。

5的理解:当进行Minor GC的时候,若对象不能被清理,就将放在Survivor中,但是如果Survivor中的空间不够放这些对象得话,就将这些对象提前放入老年代中。就要求老年代有足够的空间。若老年代最大连续可用空间大于新生代对象的大小,那肯定是安全的,若不够,就判断平均值,但也不能保证进行空间分配担保,所以说是进行一次冒险。如果担保失败,再触发Full GC。

  • Full GC的触发条件

对于Minor GC,其触发条件非常简单,当Eden空间满时,就将触发一次Minor GC。而Full GC相对复杂,有以下条件:

  1. 调用System.gc(),只是建议虚拟机执行Full GC,但是虚拟机不一定真正去执行,不建议使用这种方式,而是让虚拟机去管理内存。
  2. 老年代空间不足:老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。为了避免以上原因引起的Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
  3. 空间分配担保失败:使用复制算法的Minor需要老年代的内存空间做担保,如果担保失败会执行一次Full GC。
  4. JDK1.7之前的永久代空间不足:在jdk1.7之前,HotSpot虚拟机中的方法区是永久代实现的,永久代中存放的为一些class信息、常量、静态变量等数据。当系统中要加载类、反射的类和调用方法较多时,永久带可能会被占满,在未配置为采用CMS GC的情况下也会执行Full GC。如果Full GC仍回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。
  5. Concurrent Mode Failure:执行CMS GC的过程中有对象要放入老年代,而老年代空间不足(可能是GC过程中浮动垃圾过多导致暂时性地空间不足),便会报Concurrent Mode Failure错误,并触发Full GC。

5 类加载机制

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

类是在运行期间第一次使用时动态加载的,而不是一次性加载所有的类。因为如果一次性加载了所有的类,那么会占用很多的内存。

  • 类的生命周期

包括以下7个阶段:

  1. 加载(loading)
  2. 验证(Verification)
  3. 准备(Preparation)
  4. 解析(Resolution)
  5. 初始化(Initialization)
  6. 使用(Using)
  7. 卸载(Unloading)
  • 类的加载过程

包含了加载、验证、准备、解析和初始化这5个阶段。

  • 加载

注意加载是类加载的一个过程,不要混淆。

加载过程完成以下三件事:

  1. 通过类的完全限定名获取类的二进制字节流。
  2. 将该字节流表示的静态存储结构转为方法区的运行时存储结构
  3. 在内存中生成一个代表该类的class文件,作为方法区中该类各种数据的访问接口。

其中二进制字节流可以从以下方式获取:

  1. 从ZIP包读取,成为JAR、EAR、WAR格式的基础
  2. 从网络中获取,最典型的应用是Applet
  3. 运行时计算生成,例如动态代理技术,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 的代理类的二进制字节流。
  4. 由其它文件生成,例如由 JSP 文件生成对应的 Class 类。
  • 验证

确保class文件的字节流中包含的信息符合虚拟机的要求,并且不会危害虚拟机自身的安全。

  • 准备

类变量是static修饰的变量,准备阶段为类变量分配内存并设置初始值,使用的是方法区的内存。

实例变量是不会在这个阶段分配内存,它会在对象实例化时随着对象一起被分配到堆内存中。应该注意到,实例化是类加载的一个过程,类加载发生在所有实例化操作之前,并且加载只进行一次,实例化可以进行多次。

初始值一般为0值。例如下面的类变量value被初始化为0而不是123

public static int value = 123;

如果类变量是常量,那么它将初始化为表达式所定义的值而不是0值。例如下面的常量value被初始化为123而不是0

public static final int value = 123;
  • 解析

将常量池的符号引用替换为直接引用的过程

其中解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持java的动态绑定。

符号引用和直接引用?符号引用是以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,只要使用时能无起义地定位到目标即可。与虚拟机内存布局无关,定义引用格式文件。

直接引用:直接引用可以时直接指向目标地指针、相对偏移量或者时一个能间接定位到目标的句柄,与虚拟机内存布局有关。

  • 初始化

在初始化阶段才真正开始执行类中定义的Java程序代码。初始化阶段是虚拟机执行类构造器<clinit>() 方法的过程。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序定制的主管计划去改变类变量和其它资源。

<clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现顺序决定。特别注意:静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。例如以下代码:

public class Test {
    static {
        i = 0;                // 给变量赋值可以正常编译通过
        System.out.print(i);  // 这句编译器会提示“非法向前引用”
    }
    static int i = 1;
}

由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块的执行要优先于子类,例如如下代码

static class Parent {
    public static int A = 1;
    static {
        A = 2;
    }
}

static class Sub extends Parent {
    public static int B = A;
}

public static void main(String[] args) {
     System.out.println(Sub.B);  // 2
}

接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>() 方法。但接口与类不同的是,执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法。

虚拟机会保证一个类的 <clinit>() 方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的 <clinit>() 方法,其它线程都会阻塞等待,直到活动线程执行 <clinit>() 方法完毕。如果在一个类的 <clinit>() 方法中有耗时的操作,就可能造成多个线程阻塞,在实际过程中此种阻塞很隐蔽。

  • 类的初始化时机

  • 主动引用

虚拟机规范中并没有强制约束何时进行加载,但是严格规定了有且仅有下列5中情况时必须对类进行初始化(加载、验证、准备都会随之发生):

  1. 遇到new、getstatic、putstatic、invokestatic(invoke调用)这四条字节码指令时,如果类没有进行初始化,则必须先触发其初始化。最常见的生成这4条指令的场景是:使用new关键字实例化对象;读取或设置一个类的静态字段(被final修饰,已在编译时放入常量池的静态字段除外);调用一个静态方法;
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果没有对类进行初始化,则需要先触发其初始化。
  3. 初始化一个类的时候,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化
  4. 当启动虚拟机的时候,用户需要指定一个要执行的主类(main()方法所在的类),虚拟机会先初始化这个类。
  5. 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化;
  • 被动引用

以上 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。被动引用的常见例子包括:

通过子类引用父类的静态字段,不会导致子类初始化。

System.out.println(SubClass.value);  // value 字段在 SuperClass 中定义

通过数组定义来引用类,不会触发此类的初始化。该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的子类,其中包含了数组的属性和方法。

SuperClass[] sca = new SuperClass[10];

常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

System.out.println(ConstClass.HELLOWORLD);
  • 类和类加载器

实现“加载”动作的代码可以由程序员自己决定,实现“加载”动作的代码叫做“类加载器”

两个类相等,需要类本身相等,并且使用同一个类加载器进行加载。这是因为每一个类加载器都拥有一个独立的类名称空间。

这里的相等,包括类的calss对象的equals()方法(相等),、isAssignableFrom() 方法(被分配)、isInstance() 方法的返回结果为 true,也包括使用 instanceof 关键字做对象所属关系判定结果为 true。

  • 类加载器分类

从Java虚拟机的角度来讲,只存在以下两种不同的类加载器:

  1. 启动类加载器:使用C++实现,是虚拟机自身的一部分
  2. 所有其它的类加载器:使用Java实现,独立于虚拟机,继承至抽象类java.lang.CalssLoader。

从Java开发人员的角度看,类加载器可以划分的更细致一些:

  1. 启动类加载器(Bootstrap ClassLoader)此类加载器负责将存放在 <JRE_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 代替即可。
  2. 扩展类加载器(Extension ClassLoader)这个类加载器是由 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将 <JAVA_HOME>/lib/ext 或者被 java.ext.dir 系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。
  3. 应用程序类加载器(Application ClassLoader)这个类加载器是AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
  • 双亲委派模型

应用程序是由三种类加载器互相配合从而实现类加载,除此之外还可以加入自己定义的类加载器。

下图展示了类加载器之间的层次关系,称为双亲委派模型(Parents Delegation Model)。该模型要求除了顶层的启动类加载器外,其它的类加载器都要有自己的父类加载器。这里的父子关系一般通过组合关系(Composition)来实现,而不是继承关系(Inheritance)。

  • 工作过程

一个类加载器首先将类加载请求转发到父类加载器,只有当父类加载器无法完成时才尝试自己加载。

  • 好处

使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一。

例如 java.lang.Object 存放在 rt.jar 中,如果编写另外一个 java.lang.Object 并放到 ClassPath 中,程序可以编译通过。由于双亲委派模型的存在,所以在 rt.jar 中的 Object 比在 ClassPath 中的 Object 优先级更高,这是因为 rt.jar 中的 Object 使用的是启动类加载器,而 ClassPath 中的 Object 使用的是应用程序类加载器。rt.jar 中的 Object 优先级更高,那么程序中所有的 Object 都是这个 Object。

  • 实现

以下是抽象类 java.lang.ClassLoader 的代码片段,其中的 loadClass() 方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出 ClassNotFoundException,此时尝试自己去加载。

public abstract class ClassLoader {
    // The parent class loader for delegation
    private final ClassLoader parent;

    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    c = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }
}
  • 自定义类加载器

以下代码中的 FileSystemClassLoader 是自定义类加载器,继承自 java.lang.ClassLoader,用于加载文件系统上的类。它首先根据类的全名在文件系统上查找类的字节代码文件(.class 文件),然后读取该文件内容,最后通过 defineClass() 方法来把这些字节代码转换成 java.lang.Class 类的实例。

java.lang.ClassLoader 的 loadClass() 实现了双亲委派模型的逻辑,自定义类加载器一般不去重写它,但是需要重写 findClass() 方法。

public class FileSystemClassLoader extends ClassLoader {

    private String rootDir;

    public FileSystemClassLoader(String rootDir) {
        this.rootDir = rootDir;
    }

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classData = getClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] getClassData(String className) {
        String path = classNameToPath(className);
        try {
            InputStream ins = new FileInputStream(path);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 4096;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead;
            while ((bytesNumRead = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    private String classNameToPath(String className) {
        return rootDir + File.separatorChar
                + className.replace('.', File.separatorChar) + ".class";
    }
}

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值