Java虚拟机--笔记

本文详细介绍了Java虚拟机的内存区域,包括程序计数器、虚拟机栈、本地方法栈、堆和方法区,以及运行时常量池和直接内存。讲解了各区域的作用、内存分配策略和垃圾收集机制,如MinorGC和FullGC,以及不同的垃圾收集算法(标记-清除、复制、标记-整理和分代收集)。此外,还涵盖了类加载过程,包括加载、验证、准备、解析和初始化阶段,以及类加载器的双亲委派模型。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

好记性不如烂笔头,对Java虚拟机部分做个笔记。–阅读cyc大佬的笔记

一、运行时数据区域/Java内存区域

JDK1.8:

与之前的最大区别是:元数据区取代了永久代。
元数据区不在虚拟机中,而是使用本地内存。
方法区是一个JVM规范,永久代与元数据区本质上都是方法区的实现。

JDK1.8之后,原来永久代的数据被分到堆和元空间中。元空间存储类的元信息堆中存放静态变量和常量池等。
在这里插入图片描述

在这里插入图片描述

面试题:程序计数器、虚拟机栈、本地方法栈为什么是私有的?

	1、程序计数器私有是为了线程切换后能恢复到正确的执行位置。
	2、虚拟机栈、本地方法栈私有是为了保证线程中的局部变量不被其它线程访问到。

1、程序计数器

是线程私有的。----主要是为了线程切换后能恢复到正确的执行位置

作用:
1、字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制。如:顺序执行、选择、循环、异常处理。
2、在多线程的情况下,程序计数器用于
记录当前线程执行的位置
,当线程被切换回来的时候知道该线程上次执行到何处。

记录正在执行的虚拟机字节码指令的地址,如果正在执行的是本地方法则记录的是 undefined 地址

注意:程序计数器是唯一一个不会出现OOM的内存区域,与线程同生共死。

2、Java虚拟机栈

Java虚拟机栈是Java方法执行的内存模型,栈中存放栈帧,每个栈帧对应一个被调用的方法
从方法调用至执行完成的过程,对应一个栈帧在Java虚拟机栈中入栈和出栈的过程。

栈是线程私有的,就是说线程之间的栈是隔离的;当程序中某个线程开始执行一个方法时就会相应地创建一个栈帧并且入栈,位于栈顶,方法结束后,栈帧出栈。
栈帧用于存储局部变量表、操作数栈、常量池引用等信息。
在这里插入图片描述
栈帧:是用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区中的虚拟机栈的栈元素。
每个栈帧中包括:
1、局部变量表:用来存储方法中的局部变量(非静态变量、函数形参)。
变量为基本数据类型时,直接存储;变量为引用类型时,存储指向具体对象的引用
2、操作数栈:Java虚拟机的解释执行引擎–基于栈的执行引擎,栈即是操作数栈。
3、指向运行时常量池的引用:存储程序执行时可能用到常量的引用。
4、方法返回地址:存储 方法执行完成后的返回地址。

可以通过**-Xss参数**指定每个线程的Java虚拟机栈内存大小,JDK1.4中默认为256K,1.5之后默认为1M。

 java -Xss2M HackTheJava

该区域可能抛出以下异常
1、当线程请求的栈深度超过最大值,会抛出StackOverflowError
2、栈进行动态扩展时如果无法申请到足够内存,会抛出OutOfMemoryError

3、本地方法栈

本地方法栈类似Java虚拟机栈,也是线程私有的,Java虚拟机栈为执行Java方法服务,本地方法栈为本地方法服务。
一般是用其它语言编写的,被编译为基于本机硬件和操作系统的程序,对这些方法需要特别处理。

4、堆

堆用来存储对象本身和数组,JVM中只有一个堆,是进程中最大的一块内存,被所有线程共享。
所有的对象都在这里分配内存,是垃圾收集的主要区域("GC"堆)。

现代的垃圾收集器基本采用分代收集算法,针对不同类型的对象采用不同的垃圾回收算法。
将堆分成:新生代(Young Generation)、老年代(Old Generation)

堆不需要连续内存,可以动态增加内存,增加失败会抛出 OutOfMemoryError 异常。

可以通过 -Xms 和 -Xmx 这两个虚拟机参数来指定一个程序的堆内存大小,第一个参数设置初始值,第二个参数设置最大值。

java -Xms1M -Xmx2M HackTheJava
一般情况下堆的分区:

老年代 : 三分之二的堆空间
年轻代 : 三分之一的堆空间
	eden区: 8/10 的年轻代空间
	survivor0 : 1/10 的年轻代空间
	survivor1 : 1/10 的年轻代空间

5、方法区

方法区是一块所有线程共享内存逻辑区域,JVM中只有一个方法区,用来存储一些线程可共享内容。
线程安全的,多个线程同时访问方法区中同一个内容时,只能有一个线程装载该数据,其他线程只能等待。

方法区可存储的内容有:类的全路径名、类的直接超类的全限定名、类的访问修饰符、类的类型(类或接口)、类的直接接口全限定名的有序列表、常量池(字段,方法信息,静态变量,类型引用(class))等。

用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等。

和堆一样不需要连续的内存,可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。
对这块区域垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般难以实现。

HotSpot 虚拟机把它当成永久代来进行垃圾回收。但很难确定永久代的大小,因为它受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常。

为了更容易管理方法区,从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。
方法区是一个JVM规范,永久代与元空间都是一种实现方式。
JDK1.8之后,原来永久代的数据被分到堆和元空间中。元空间存储类的元信息,堆中存放静态变量和常量池等。

面试题:为什么要将永久代替换为元空间?

1、永久代有一个JVM本身设置的固定大小上限,无法进行调整。
而元空间使用直接内存,受本机可用内存的限制。
2、元空间内存放类的元数据,存放数量不再由永久代的最大大小限制,可以加载更多的类。
3、JRockit中没有永久代这个东西,JDK1.8合并HotSpot和JRockit也不需要这个。

6、运行时常量池

是方法区的一部分。
Class文件中的常量池(编译器生成的字面量和符号引用)会在类加载后被放入这个区域。
除了编译期生成的常量,还允许动态生成,如String类的intern()。

7、直接内存

不是虚拟机运行时数据区的一部分,但是也被频繁使用。

JDK1.4中新引入了NIO类,可以使用Native函数库直接分配堆外内存,然后通过Java堆里的DirectByteBuffer 对象作为这块内存的引用进行操作。
避免了在堆内存和堆外内存来回拷贝数据。

二、垃圾收集

针对堆和方法区进行。
程序计数器、虚拟机栈、本地方法栈这三个区域属于线程私有,只存在于线程的生命周期内,线程结束即消失。

面试题:如何判断一个常量是废弃常量?

运⾏时常量池主要回收的是废弃的常量。

假如在常量池中存在字符串 “abc”,如果当前没有任何String对象引⽤该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发⽣内存回收的话⽽且有必要的话, “abc” 就会被系统清理出常量池。

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

1、引用计数算法

为对象添加一个引用计数器,当对象增加一个引用时计数器加一引用失效时计数器减一
引用计数为0的对象可被回收

若出现两个对象循环引用的情况,此时引用计数器永不为0,无法回收。
循环引用的存在,Java虚拟机不使用引用计数器算法。

2、可达性分析算法

GC Roots为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。
在这里插入图片描述
Java虚拟机使用可达性分析算法判断对象是否可被回收。
GC Roots一般包含:
1、虚拟机栈中局部变量表中引用的对象
2、本地方法栈中JNI中引用的对象
3、方法区中类静态属性引用的对象
4、方法区中的常量引用的对象

3、方法区的回收

方法区主要存放永久代对象,主要是对常量池的回收和对类的卸载
为避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类的卸载功能。

面试题:如何判断一个类是无用的类?

类的卸载条件很多,需满足以下三个条件,满足条件也不一定会被卸载:
1、该类所有实例都已经被回收,此时堆中不存在该类的任何实例。
2、加载该类的ClassLoader已经被回收。
3、该类对应的Class对象没有在任何地方被引用===无法在任何地方通过反射访问该类方法。

4、finalize()

用于关闭外部资源。
但是try-finally等方式可以做得更好,并且finalize()方法运行代价大,不确定性大,无法保证各对象调用顺序,最好不使用。

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

2、四大引用类型

判断对象是否可被回收与引用有关。

1、强引用

被强引用关联的对象不会被回收。

使用new一个新对象的方式创建强引用。

Object obj = new Object();
2、软引用

软引用关联的对象只有在内存不够的情况下才会被回收。

使用SoftReference类创建软引用。

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

被弱引用关联的对象一定会被回收,只能存活到下一次垃圾回收发生之前。

使用WeakReference类创建弱引用。

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

又称幽灵引用或幻影引用。
一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。
设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。

使用PhantomReference来创建虚引用。

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;

3、垃圾收集算法

1、标记-清除

在这里插入图片描述

标记阶段,程序会检查每个对象是否为活动对象,若是活动对象则程序会在对象头部打上标记。

清除阶段,会进行对象回收并取消标志位。
另外,还会判断回收后的分块与前一个空闲分块是否连续;若连续,会合并这两个分块。
回收对象就是把对象作为分块,连接到被称为 “空闲链表” 的单向链表,之后进行分配时只需遍历这个空闲链表,就可找到分块。

在分配时,程序会搜索空闲链表寻找空间大于等于新对象大小size的块block。
如果它找到的块等于 size,会直接返回这个分块;如果找到的块大于 size,会将块分割成大小为 size 与 (block - size) 的两部分,返回大小为 size 的分块,并把大小为 (block - size) 的块返回给空闲链表。

不足:
1、标记和清除过程效率都不高
2、会产生大量不连续的内存碎片,导致无法给大对象分配内存。

2、标记-整理

在这里插入图片描述

让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存

优点:不会产生内存碎片
缺点:需要移动大量对象,处理效率比较低

3、复制

在这里插入图片描述
内存划分为大小相等的两块每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。

主要不足是:只使用了内存的一半

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

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

---HotSpot VM是Sun JDK和Open JDK 中所带的虚拟机,也是目前使用范围最广的虚拟机。
4、分代收集

为了提升GC效率,现在的商业虚拟机采用分代收集算法,根据对象存活周期将内存划分为几块,不同块采用适当的收集算法

一般将堆分为新生代和老年代。
新生代使用:复制算法–每次收集都有大量对象死去
老年代使用:标记-清除算法 或者 标记-整理算法–存活对象多

为什么新生代采用复制算法,老年代采用标整算法?

新生代使用复制算法---存活下来的对象是少数,需要复制的对象少
因为新生代对象的生存时间比较短,80%的都要回收的对象,采用标记-清除算法则内存碎片化比较严重,标记整理算法需要移动大量的对象,处理效率低
采用复制算法可以灵活高效,便于整理空间。

老年代采用标记整理---存活对象多,不能使用复制算法,占空间
标记整理算法主要是为了解决标记清除算法存在内存碎片的问题,又解决了复制算法两个Survivor区的问题,
因为老年代的空间比较大,不可能采用复制算法,特别占用内存空间

4、垃圾收集器

在这里插入图片描述
以上是HotSpot虚拟机中的7个垃圾收集器,连线表示可以配合使用。
因为目前为止还没有完美的收集器出现,更没有万能的收集器,只是针对具体应用最合适的收集器,进行分代收集(新生代老年代)

单线程与多线程:单线程指垃圾收集器只使用一个线程,多线程使用多个线程。
串行与并行:
串行指的是垃圾收集器与用户程序交替执行,执行垃圾收集的时候需要停顿用户程序;
并行指的是垃圾收集器和用户程序同时执行。CMS和G1以并行的方式执行。

1、Serial收集器

在这里插入图片描述
Serial收集器以串行的方式执行。
单线程的收集器,只会使用一个线程进行垃圾收集工作,会暂停所有的用户线程,只有当垃圾回收完成时,才会重新唤醒主线程继续执行。所以不适合服务器环境

优点: 简单高效,单个CPU环境下,没有线程交互的开销,拥有最高的单线程收集效率。

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

2、Serial Old收集器

在这里插入图片描述
Serial收集器的老年代版本,也是给Client场景下的虚拟机使用。

如果用在Server场景下,有两大用途:
1、在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
2、作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

3、ParNew收集器

在这里插入图片描述
ParNew收集器是Serial收集器的多线程版本

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

4、Parallel Scavenge收集器

多线程收集器

其他收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量,因此被称为**“吞吐量优先”收集器**。
吞吐量指CPU用于运行用户程序的时间占总时间的比值。

停顿时间越短就越适合与用户交互的程序,良好的响应速度能提升用户体验。
高吞吐量可以高效率利用CPU时间,尽快完成程序运算任务,适合在后台运算而不需要太多交互的任务。

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

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

5、Parallel Old收集器

在这里插入图片描述
Parallel Scavenge收集器的老年代版本。

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

6、CMS收集器

在这里插入图片描述
CMS(Concurrent Mark Sweep), 并发标记清除,Mark Sweep 指的是标记 - 清除算法。

四个流程:
1、初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,速度快,需要停顿。
2、并发标记:进行GC Roots Tracing的过程,在整个回收过程中耗时最长,不需要停顿。
3、重新标记:为了修正并发标记期间因用户程序继续运作导致标记产生变动的那部分对象的标记记录,需要停顿。
4、并发清除:不需要停顿。

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

缺点:
1、吞吐量低:停顿时间低是以牺牲吞吐量为代价的,导致CPU利用率不够高。
2、无法处理浮动垃圾,可能出现Concurrent Mode Failure。
浮动垃圾指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次GC时才能进行回收。
由于浮动垃圾的存在,因此需要预留出一部分内存,CMS不能像其它收集器那样等老年代快满的时候再回收。
如果预留的内存不够存放浮动垃圾,就会出现Concurrent Mode Failure,这时虚拟机将临时启用Serial Old替代CMS
3、标记-清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间分配当前对象,不得不提前出发一次Full GC。

7、G1收集器

G1(Garbage-First),面向服务端应用的垃圾收集器,在多CPU和大内存的场景下有很好的性能
—旨在未来替换掉CMS收集器
在这里插入图片描述

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

G1收集器把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。
在这里插入图片描述
引入Region的概念,将原来的一整块内存空间划分成多个小空间,使每个小空间可以单独进行垃圾回收。

使可预测的停顿时间模型成为可能–
–通过记录每个Region 垃圾回收时间 及 回收所获得的空间 (通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。

每个Region都有一个Remembered Set,用于记录该Region对象的引用对象所在的Region。
通过Remembered Set,可达性分析的时候就可以避免全堆扫描。----有路径压缩的感觉

在这里插入图片描述

如果不计算维护Remembered Set的操作, G1收集器可以分为以下步骤:
1、初始标记
2、并发标记
**3、最终标记:**为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的Remembered Set Logs里面,最终标记阶段需要将Remembered Set Logs的数据合并到Remembered Set中。这阶段需要停顿线程,但是可以并行执行。
**4、筛选回收:**首先对每个Region中的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。
此阶段也可做到与用户程序一起并发执行,但因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

G1收集器具备的特点:
**1、空间整合:**整体来看是基于标记-整理算法实现的收集器,从局部–两个Region之间–上来看是基于复制算法实现的,这意味着运行期间不会产生内存空间碎片。
**2、可预测的停顿:**能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不能超过N毫秒。

G1的缺点:

region 大小和大对象很难保证一致,这会导致空间的浪费;特别大的对象是可能占用超过一个 region 的。并且,region 太小不合适,会令你在分配大对象时更难找到连续空间,这是一个长久存在的情况。

三、内存分配策略与回收策略

1、Minor GC和Full GC

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

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

2、内存分配策略

1、对象优先在Eden分配
大多数情况下,对象在新生代Eden上分配,当Eden空间不够时,发起Minor GC。

2、大对象直接进入老年代
大对象:需要连续内存空间的对象,如很长的字符串或数组。
经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。

3、长期存活的对象进入老年代
为对象定义年龄计数器,对象在Eden出生并经过Minor GC依然存活,将其移动到Survivor中,年龄就增加一岁,增加到一定年龄则移动到老年代中。

-XX:MaxTenuringThreshold 用来定义年龄的阈值。

4、动态对象年龄判定
虚拟机并不是永远要求对象的年龄必须达到MaxTenuringThreshold才移入老年代,如果在Survivor中相同年龄所有对象大小的总和大于Survivor空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代

5、空间分配担保
在发生Minor GC之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,Minor GC可以确认是安全的。

如果不成立,虚拟机会查看HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,尝试进行一次Minor GC;如果小于,或HandlePromotionFailure 的值不允许冒险,就需要进行一次Full GC。

3、Full GC的触发条件

Minor GC触发条件:当Eden空间满时,将触发一次Minor GC。

Full GC的触发条件:

1、调用System.gc()
只是建议虚拟机执行Full GC(),但是虚拟机不一定真正去执行。

2、老年代空间不足
大对象直接进入老年代、长期存活的对象进入老年代等会导致老年代空间不足。

尽量不要创建过大的对象及数组。
还可以通过-Xmn虚拟机参数调大新生代的大小,让对象尽量在新生代被回收,不进入老年代。
还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。

3、空间分配担保失败
使用复制算法的Minor GC需要老年代的内存空间做担保,担保失败会执行一次Full GC。

4、JDK1.7及以前的永久代空间不足
在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。

当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。

为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。

5、Concurrent Mode Failure

执行CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是GC过程中浮动垃圾过多导致暂时性的空间不足),便会报Concurrent Mode Failure错误,触发Full GC。

面试题:Java对象的创建过程/new一个对象的过程?

在这里插入图片描述

1、类加载检查:首先检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这符号引用代表的类是否已被加载、解析、验证、初始化,如果没有先执行类加载过程(class.forname())。
2、分配内存:类加载检查通过后,虚拟机会为新生对象分配内存。主要有指针碰撞、空闲列表两种方式----由Java堆是否规整决定,Java堆是否规整由垃圾收集器采用的垃圾收集器是否带有压缩整理功能决定。
(1)指针碰撞
适用:当虚拟机使用复制算法或标记整理算法实现的垃圾收集器时,内存区域都是规整的。
原理:用过的内存放在一边,空闲的内存在另一边,中间用一个指针作为分界点,当需要为新对象分配内存时只需把指针向空闲的一边移动一段与对象大小相等的距离。
垃圾收集器:Serial、ParNew
(2)空闲列表
适用:当虚拟机使用标记清除算法实现的垃圾收集器时,内存都是碎片化的
原理:虚拟机维护一个列表,列表中记录哪些内存块可用,在分配内存的时候,找一块足够大的内存空间给对象实例,更新列表记录。
垃圾收集器:CMS
内存分配的并发问题:使用以下两种保证线程安全
(1)CAS+失败重试:每次不加锁⽽是假设没有冲突⽽去完成某项操作,如果因为冲突失败就重试,直到成功为⽌。 CAS+失败重试 保证更新操作的原子性。
(2)TLAB:为每个线程预先在Eden区分配一块内存,JVM在给线程中对象分配内存时,首先在TLAB分配。当放不下时,再采用CAS分配。

3、初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间初始化为零值(不包括对象头)。
保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤。
4、设置对象头信息:如所属类,元数据信息,哈希码,gc分代年龄,等等。
5、执行init()方法:执行实例初始化代码。–返回创建出来对象的引用。
在这里插入图片描述

面试题:对象的两种访问定位方式?

Java程序通过栈上的Reference数据来操作堆上的具体对象。
1、句柄:Java堆中将会划分出⼀块内存来作为句柄池, reference 中存储的就是对象的句柄地址,⽽句柄中包含了对象实例数据与类型数据各自的具体地址信息;
优势:reference中存储稳定的句柄地址,对象被移动只需要改变句柄中的实例数据指针,reference本身不需要修改。
在这里插入图片描述

2、直接指针:Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference 中存储的直接就是对象的地址。
优势:速度快,节省了一次指针定位的时间开销。
在这里插入图片描述

四、类加载机制

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

1、类的生命周期

在这里插入图片描述
Loading --> Verification --> Preparation --> Resolution --> Initialiazation --> Using --> Unloading

2、类的加载过程

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

1、加载

加载过程完成:

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

二进制字节流可以通过一下方式中获取:

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

2、验证
确保Class文件的字节流中包含的信息符合当前虚拟机的技术,并不会危害虚拟机自身安全。

3、准备

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

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

//初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123。
public static int value = 123;


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

4、解析

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

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

Java的动态绑定和静态绑定-多态

当子类和父类存在同一个方法时,子类重写父类的方法,程序在运行时调用方法,是调用父类方法还是子类方法?
---确定这种调用何种方法的操作称为绑定,绑定分为静态绑定和动态绑定。
	1、静态绑定
		--静态绑定是在程序执行前就已经被绑定了,程序编译过程中就已经知道这个方法是哪个类中的方法。
		--Java中只有private、static、final修饰的方法及构造方法是静态绑定。
			--private方法不能被继承,不存在调用其子类的对象,只能调用对象自身,因此private方法和定义该方法的类绑在一起
			--static方法,类方法,属于类文件。不依赖对象而存在,调用的时候就知道是哪个类
			--final方法不能被重写,调用的方法是一样的
			总结:如果一个方法不可被继承或者继承后不能被重写,采用的即是静态绑定。
	2、动态绑定
		--编译器在每次调用方法时都要进行搜索,时间开销大。因此虚拟机会预先为每个类创建一个方法表,其中列出了所有方法的签名和实际调用的方法。
		--动态绑定的过程:
				1、虚拟机提取对象的实际类型的方法表
				2、虚拟机搜索方法签名(方法签名包括:方法名、参数的数量和类型),此时虚拟机已经知道调用哪种方法
				3、虚拟机调用方法

5、初始化

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

<clinit>()是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序由语句在源文件中出现顺序决定。
注意:
1、静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量在语句块中只能赋值不能访问。

public class Test {

    static {
        i = 0;                // 给变量赋值可以正常编译通过
        System.out.print(i);  // 这句编译器会提示“非法向前引用”
    }
    static int i = 1;//i变量定义在静态语句块之后
}

2、虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。
因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。
由于父类的<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
}

3、接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口也会生成<clinit>()方法。
接口中的属性都是static final类型的常量,在准备阶段就已经初始化。
不同的是:
执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。
只有当父接口中定义的变量使用时,父接口才会初始化。
另外接口的实现类在初始化时也不会执行接口的<clinit>()方法。

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

init() 和 clinit() 的区别:

1、两个方法的执行时机不同
	init() 是对象构造器方法,程序在执行new一个对象调用该对象类的constructor方法时才会执行init()方法
	clinit()时类构造器方法,jvm在执行类的  加载-验证-准备-解析-初始化  中的初始化阶段,jvm会调用clinit()方法。
	
2、两个方法的执行目的不同
	init()是instance实例构造器,对非静态变量进行初始化
	clinit() 是class类构造器,对静态变量、静态代码块进行初始化

3、类初始化时机

1、主动引用

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

1、遇到new、getstatic、putstatic、invokestatic时,若类没有进行过初始化,必须先触发其初始化。
–使用new关键字实例化对象时
–读取或者设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)时
–调用一个类的静态方法时
2、使用java.lang.reflect包的方法对类进行反射调用时,如果类没有进行初始化,需要先触发其初始化。
3、初始化一个类的时候,如果发现父类还没有进行过初始化,则需要先触发其父类的初始化
4、当虚拟机启动时,用户需要制定一个要指向的主类(包含main()方法的类),虚拟机会先初始化这个主类
5、JDK1.7中,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化;

2、被动引用

以上五种称为对一个类进行主动引用。除这五种,所有引用类的方式都不会触发初始化,称为被动引用。

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

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

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

SuperClass[] sca = new SuperClass[10];

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

System.out.println(ConstClass.HELLOWORLD);

4、类与类加载器

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

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

5、类加载器分类

Java虚拟机的角度,分为:
1、启动类加载器(Bootstrap ClassLoader),使用C++实现,是虚拟机自身的一部分。
2、所有其他类的加载器,使用Java实现,独立于虚拟机,继承自抽象类java.lang.ClassLoader。

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)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

rt.jar:-----(runtime,rt),是java程序在运行时必不可少的文件。包含jdk的基础类库。也就是Java doc里面的所有类的class文件
tools.jar:-----主要包含一些工具的类库。系统用来编译一个类的时候用到的,javac的时候。
dt.jar:----是关于运行环境的类库,主要是swing的包。

----rt.jar在%JAVA_HOME%\jre\lib,dt.jar和tools.jar在%JAVA_HOME%\lib下。
----path变量的含义就是在任何路径下都可以识别java、javac命令。
----classpath变量的含义就是告诉JVM要使用或执行的class放在什么路径上,便于JVM加载class文件

6、双亲委派模型

类加载器之间的层次关系:
在这里插入图片描述
类加载器之间的层次关系,称为双亲委派模型(Parents Delegation Model)
该模型要求除了顶层的启动类加载器外,其他的类加载器都要有自己的父类加载器。
这里的父子关系一般通过组合关系实现,而不是继承。

1、工作过程

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

2、好处

使得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。

3、实现

loadClass() 方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出 ClassNotFoundException,此时尝试自己去加载。

7、自定义类加载器实现

以下代码中的 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、付费专栏及课程。

余额充值