如何判断对象可以被回收
五种引用(面试常考):
- 强引用
- 弱引用
- 软引用
- 虚引用
- 终结器引用
image-20220811163328291
1 强引用
- 只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收
如上图、只有B、C对象都不引用A1对象时,A1对象才会在垃圾回收时被回收;
2 软引用(SoftReference)
- 仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用对象(实在不行了才回收软引用对象)
- 可以配合引用队列来释放软引用自身(因为软引用对象自身也是占一点内存的)
软引用的使用
image-20220811163603544
image-20220811163628973
可见前四次已经被回收了;
引用队列的使用:
如果在垃圾回收时发现内存不足,在回收软引用所指向的对象时,软引用本身不会被清理(就是上图结果中的null值)
如果想要清理软引用,需要使用引用队列
image-20220811163843846
3 弱引用(WeakReference)
- 仅有弱引用引用该对象时,在垃圾回收时,**(full gc时)无论内存是否充足,都会回收**弱引用对象
- 可以配合引用队列来释放弱引用自身
image-20220811164041023
结合引用队列同软引用相似
4 虚引用(PhantomReference)
- 必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队由 Reference Handler 线程调用虚引用相关方法释放直接内存
5 终结器引用(FinalReference)
- 无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize方法,第二次 GC 时才能回收被引用对象
一、垃圾回收
1、如何判断一个对象会被回收?
1.1 引用计数法
所谓引用计数法,每个对象额外保存一个计数属性,如果有一个对象引用了它,那么该属性会加1,例,
A a=new A();
A a2=a;
上面这段代码会在堆中生成一个A的对象实例,且a、a2都指向了该对象,那么该对象的计数属性便是2,又如,
A a=new A();
A a2=a;
a=null;
a2=null;
这时a、a2均指向了null,那么A的对象实例的计数属性则为0,按照引用计数法的定义这时该实例可以被回收。
看上去该算法很完美,但是java中为什么没用,有个问题如果出现循环引用怎么办,
A a=new A();
B b=new B();
a.b=b;
b.a=a;
a=null;
b=null;
上面的代码在堆中会有一个A的实例一个B的实例,且计数属性均为1,执行了第3、4两行代码后,两个实例的引用计数均为2,执行了5、6两行代码后两个实例的计数属性均为1,这时a、b均指向了null,但是堆中的两个实例的计数属性的值却不为0,那么这两个实例无法回收,存在内存泄漏的风险;
1.2 可达性分析法
所谓可达性分析法,就是从一些称为引用链(GC ROOTS)的对象作为起点,从这些节点向下搜索,搜索走过的路径称为引用链(reference chain),当一个对象到GC ROOTS没有引用链的时则该对象不可达,该对象可以被回收。
哪些对象是引用链那,虚拟机栈是java程序中方法执行的区域,每个方法的执行对应着一个栈帧的入栈和出栈,方法执行完了其申请的内存便可以释放,所以栈帧中的对象可作为引用链对象,同时本地方法栈的情况也是类似的;在方法区中存在常量和类静态变量,这两种变量也可以作为引用链对象,总结下来有下面几种,
- 虚拟机栈中的局部变量表中的对象;
- 方法区中常量引用的对象;
- 方法区中类的静态变量引用的对象;
- 本地方法栈中JNI引用的对象;
使用可达性分析方法判断为可回收的对象,还有一次逃过回收的机会,那就是在Object类中有finalize()方法,如果在该方法中没有与上述的引用链建立链接,那么该对象则确定要被回收。
2、垃圾回收算法(重要)
标记清除算法
定义:标记清除算法顾名思义,是指在虚拟机执行垃圾回收的过程中,先采用标记算法确定可回收对象,然后垃圾收集器根据标识清除相应的内容,给堆内存腾出相应的空间
image-20220811164255455
清除后,对于腾出内存空间并不是将内存空间的字节清0,而是会把被清除对象所占用内存的起始结束的地址记录下来,放入空闲的地址列表中,下次分配内存的时候,再选择合适的位置存入,直接覆盖
优点:速度快;
缺点:容易产生大量的内存碎片,可能无法满足大对象的内存分配,一旦导致无法分配对象,那就会导致jvm启动gc,一旦启动gc,我们的应用程序就会暂停,这就导致应用的响应速度变慢
标记整理算法
特定:速度慢、可以有效避免因内存碎片而导致的问题,但是因为整体需要消耗一定的时间,所以效率较低。
- 标记: 先按照根搜索算法进行遍历, 对于遍历到的对象进行标记, 直到遍历结束.
- 整理: 在遍历结束后, 对于标记过的对象, 把它们从内存开始的区域按顺序依次摆好, 整整齐齐的, 中间没有任何的缝隙. 在摆放完最后一个标记过的对象后, 把之后的内存区域直接回收掉.
复制算法
复制算法将内存分为等大小的两个区域,FROM和TO(TO中始终为空)。先将被GC Root引用的对象从FROM放入TO中,再回收不被GC Root引用的对象。然后交换FROM和TO。这样也可以避免内存碎片的问题,但是会占用双倍的内存空间。
image-20220811164455902
3、垃圾回收机制
回收流程
① 新创建的对象都被放在新生代的伊甸园中
image-20220811164541767
② 当伊甸园空间不足时,会采用复制算法进行垃圾回收,这时的回收叫做Minor GC;把伊甸园和幸存区From存活的对象先复制到幸存区To中,此时存活的对象寿命+1,并清理掉未存活的对象,最后再交换幸存区From和幸存区To;
image-20220811164612870
image-20220811164720253
image-20220811164727571
③ 再次创建对象,若新生代的伊甸园又满了,则同上;
④ 如果经历多次垃圾回收,某一对象均未被回收,寿命不断+1,当寿命达到阈值时(最大为15,4bit)就会被放入老年代中;
image-20220811164852925
⑤ 如果老年代中的内存都满了,就会先触发Minor GC 如果内存还是不足,则会触发Full GC,扫描新生代和老年代中所有不再使用的对象并回收、
总结
- 对象首先分配在伊甸园区域
- 新生代空间不足时,触发 minor gc,伊甸园和 from 存活的对象使用 copy 复制到 to 中,存活的对象年龄加 1并且交换 from to
- minor gc会引发 stop the world,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
- 当对象寿命超过阈值时,会晋升至老年代,最大寿命是15(4bit)
- 当老年代空间不足,会先尝试触发 minor gc,如果之后空间仍不足,那么触发 full gc,STW的时间更长
GC 分析
大对象处理策略:
当遇到一个较大的对象时,就算新生代的伊甸园为空,也无法容纳该对象时,会将该对象直接晋升为老年代
线程内存溢出:
某个线程的内存溢出了而抛异常(out of memory),不会让其他的线程结束运行
这是因为当一个线程抛出OOM异常后,它所占据的内存资源会全部被释放掉,从而不会影响其他线程的运行,进程依然正常
4、垃圾回收器
串行
- 单线程
- 堆内存较小,适合个人电脑
吞吐量优先
- 多线程
- 堆内存较大,多核 cpu
- 让单位时间内,STW 的时间最短 0.2 0.2 = 0.4,垃圾回收时间占比最低,这样就称吞吐量高(少餐多食)
响应时间优先
- 多线程
- 堆内存较大,多核 cpu
- 尽可能让单次 STW 的时间最短 0.1 0.1 0.1 0.1 0.1 = 0.5(少食多餐)
image-20220811170019886
安全点:
让其他线程都在这个点停下来,以免垃圾回收时移动对象地址,使得其他线程找不到被移动的对象;因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态
Serial 收集器:
Serial收集器是最基本的、发展历史最悠久的收集器
特点:单线程、简单高效(与其他收集器的单线程相比),用于新生代采用复制算法。对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程手机效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)
Serial Old 收集器:
Serial Old是Serial收集器的老年代版本
特点:同样是单线程收集器,采用标记-整理算法
吞吐量优先
-XX:+UseParallelGC ~ -XX:+UserParallelOldGC:
开启吞吐量优先的回收器,1.8版本默认开启;UseParallelGC是新生代的,采用复制算法;UserParallelOldGC是在老年代的,采用标记整理算法
-XX:+UseAdaptiveSizePolicy:
开启这个将采用自适应的大小调整策略,调整新生代的大小,包括堆的大小和晋升老年代的阈值大小等;种调节方式称为GC的自适应调节策略
-XX:GCTimeRatio=ratio:
调整吞吐量的目标,即垃圾回收的时间与总时间的占比(1/(1+ratio)),默认ratio=99,1/(1+ratio))= 0.01,即垃圾回收的时间不能超过总时间的1%(比如总时间100分钟,垃圾回收的时间不能超过1分钟,如果超过1分钟,则GC会自适应的调整大小)
-XX:MaxGCPauseMillis=ms:
指的是暂停的毫秒数,默认200ms,即上图红线(和Ratio对立,折中选取)
-XX:ParallelGCThreads=n:
设置垃圾回收时运行的线程数
image-20220811165832530
CMS 收集器
Concurrent Mark Sweep,一种以获取最短回收停顿时间为目标的老年代收集器
特点:基于标记-清除算法实现。并发收集、低停顿,但是会产生内存碎片
应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如web程序、b/s服务
CMS收集器的运行过程分为下列4步:
初始标记:标记GC Roots能直接到的对象。速度很快但是仍存在Stop The World问题
并发标记:进行GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行
重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在Stop The World问题
并发清除:对标记的对象进行清除回收
CMS收集器的内存回收过程是与用户线程一起并发执行的
图解:
5、垃圾回收调优
2.5.1 确定调优领域
- 内存
- 锁竞争
- CPU占用
- IO
- GC
2.5.2确定目标
低延迟/高吞吐量? 选择合适的GC
- CMS(JDK8默认)、 G1(JDK9推荐) 、ZGC(JDK12体验)
- ParallelGC(高吞吐量)
- Zing(另一种虚拟机)
2.5.3最快的GC是不发生GC
首先排除减少因为自身编写的代码而引发的内存问题
- 查看Full GC前后的内存占用,考虑以下几个问题
- 数据是不是太多?(比如select *)
- 数据表示是否太臃肿
- 对象图
- 对象大小(比如用Integer换成int会小很多)
- 是否存在内存泄漏(采用软、弱引用;第三方缓存实现等)
2.5.4新生代调优
新生代的特点
- 所有的new操作分配内存都是非常廉价的
- 死亡对象回收零代价
- 大部分对象用过即死(朝生夕死)
- MInor GC 所用时间远小于Full GC
问:新生代内存越大越好么?
答:不是
- 新生代内存太小:频繁触发Minor GC,会STW,会使得吞吐量下降
- 新生代内存太大:老年代内存占比有所降低,会更频繁地触发Full GC。而且触发Minor GC时,清理新生代所花费的时间会更长
- 新生代内存设置为能容纳所有【并发量*(请求-响应)】的数据为宜
2.5.5幸存区调优
- 幸存区需要能够保存当前活跃对象+需要晋升的对象
- 晋升阈值配置得当,让长时间存活的对象尽快晋升
-XX:MaxTenuringThreshold=threshold
-XX:+PrintTenuringDistribution
2.5.6老年代调优
以 CMS 为例
- CMS 的老年代内存越大越好
- 先尝试不做调优,如果没有 Full GC 那么已经…,否则先尝试调优新生代
- 观察发生 Full GC 时老年代内存占用,将老年代内存预设调大 1/4 ~ 1/3
-XX:CMSInitiatingOccupancyFractinotallow=percent
二、垃圾回收器
1、三色标记法
CMS和G1在并发标记时使用的是同一个算法:三色标记法,使用白灰黑三种颜色标记对象。白色是未标记;灰色自身被标记,引用的对象未标记;黑色自身与引用对象都已标记。
GC 开始前所有对象都是白色,GC 一开始所有根能够直达的对象被压到栈中,待搜索,此时颜色是灰色。然后灰色对象依次从栈中取出搜索子对象,子对象也会被涂为灰色,入栈。当其所有的子对象都涂为灰色之后该对象被涂为黑色。当 GC 结束之后灰色对象将全部没了,剩下黑色的为存活对象,白色的为垃圾。
2、CMS
CMS(Concurrent Mark Sweep)垃圾回收器是第一个关注 GC 停顿时间的垃圾收集器。 在这之前的垃圾回收器,要么就是串行垃圾回收方式,要么就是关注系统吞吐量。JDK 1.5 时引入,JDK9 被标记弃用,JDK14 被移除。
CMS 垃圾回收器通过三色标记算法,实现了垃圾回收线程与用户线程并发执行,从而极大地降低了系统响应时间。
CMS收集器工作时,GC工作线程与用户线程可以并发执行,以此来达到降低收集停顿时间的目的。
CMS是只能用于老年代的垃圾回收器。
优点:并发收集、低停顿。
缺点:
- 对 CPU 资源敏感;
- 无法处理浮动垃圾;
- 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。
CMS 垃圾回收器其实通过「标记-清除」算法实现的,它的运行过程分为 4 个步骤,包括:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
(1)初始标记
工作模式: JDK7之前单线程,JDK8之后多线程
目标: 标记所有的根对象,包括根对象直接引用的对象,以及被年轻代中所有存活的对象所引用的老年代对象(只是标记一下GC Roots能直接关联到的对象,速度很快)。
运行模式:会停机执行,用户线程不能工作。
(2)并发标记
运行模式:“并发标记”阶段就是与应用程序同时运行,不用停机的阶段。
目标:对「初始标记阶段」标记的对象进行整个引用链的扫描。
通过垃圾回收线程与用户线程并发执行,这样就降低垃圾回收的时间。
这也是 CMS 能极大降低 GC 停顿时间的核心原因,但这也带来了一些问题,即:并发标记的时候,引用可能发生变化,因此可能发生漏标(本应该回收的垃圾没有被回收)和多标(本不应该回收的垃圾被回收)了。
(3)重新标记
目标:对「并发标记」阶段出现的问题进行校正。
运行模式:不能与应用程序同时运行,需要停机执行的阶段。
(4)并发清除
目标:将标记为垃圾的对象进行清除。
运行模式:可以与应用程序同时运行,不用停机的阶段。
3、G1
2.1 介绍
在 JDK 1.7 时引入,在 JDK 9 时取代 CMS 成为了默认的垃圾收集器。G1 有五个属性:分代、增量、并行、标记整理、STW。整体是标记整理算法,区域之间使用复制算法。
G1是一个分代的,增量的,并行与并发的标记-复制垃圾回收器。它的设计目标是为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。
STW
在一个垃圾回收事件中,所有Java应用线程会被暂停。
分代
把堆分成年轻代,老年代。年轻代又分为Eden区,Survivor区。
分区
G1采用了分区(Region)的思路,将整个堆空间分成若干个大小相等的内存区域,每次分配对象空间将逐段地使用内存。因此,在堆的使用上,G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可。每个分区都可以是年轻代和老年代,可以按需在年轻代和老年代之间切换。
启动时可以通过参数-XX:G1HeapReginotallow=n可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区。
卡片
在每个分区内部又被分成了若干个大小为512 Byte卡片(Card),标识堆内存最小可用粒度。
所有分区的卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查找该引用对象(见RSet)。每次对内存的回收,都是对指定分区的卡片进行处理。
2.2 G1回收细节详解
(1)年轻代收集阶段
当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。
年轻代收集首先做的就是迁移存活对象,它使用单eden,双survivor进行复制算法,它将存活的对象从eden分区转移到survivor分区,survivor分区内的某些对象达到了任期阈值之后,会晋升到老年代分区中。原有的年轻代分区会被整个回收掉
同时,年轻代收集还负责维护对象年龄,存活对象经历过年轻代收集总次数等信息。G1将晋升对象的尺寸总和和它们的年龄信息维护到年龄表中,结合年龄表、survivor占比(--XX:TargetSurvivorRatio 缺省50%)、最大任期阈值(--XX:MaxTenuringThreshold 缺省为15)来计算出一个合适的任期阈值
调优:我们可以通过--XX:MaxGCPauseMillis,调优年轻代收集,缩小暂停时间
(2)并发标记阶段
随着时间推移,越来越多的对象晋升到老年代中,当老年代占比(相对于Java总堆而言)达到IHOP参数(上图的IHOP Trigger)之后,那么G1首先会触发并发标记周期(上图的Concurrent Marking Cycle),当完成后才会开始下一小节的混合垃圾收集周期
G1的并发标记循环分5个阶段:
第一阶段:初始标记(上图Young Collection with Initial Mark),收集所有GC根(对象的起源指针,根引用),STW(服务会停顿),在年轻代完成
第二阶段:根区间扫描,标记所有幸存者区间的对象引用
第三阶段:并发标记(上图Concurrent Marking),标记存活对象
第四阶段:重新标记(上图Remark),是最后一个标记阶段,STW,很短,完成所有标记工作
第五阶段:清除(上图Clean),回收没有存活对象的Region并加入可用Region队列
初始标记与重新标记都会STW。
(3)混合收集阶段
Mixed GC并不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old以及大对象区。
XX:G1MixedGCLiveThresholdPercent,默认为65%。
(4)Full GC(可选过程四)
G1的初衷就是要避免Fu1l GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World) ,使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。
要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢? 比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到full gc, 这种情况可以通过增大内存解决。
导致G1Full GC的原因可能有两个: .
1. 回收的时候没有足够的to-space来存放晋升的对象
2.并发处理过程没完成空间就耗尽了
2.3 G1和CMS的区别
G1和CMS的区别:
- G1垃圾回收器是compacting的,因此其回收得到的空间是连续的。这避免了CMS回收器因为不连续空间所造成的问题。如需要更大的堆空间,更多的floating garbage。连续空间意味着G1垃圾回收器可以不必采用空闲链表的内存分配方式,而可以直接采用bump-the-pointer的方式;
- G1回收器的内存与CMS回收器要求的内存模型有极大的不同。G1将内存划分一个个固定大小的region,每个region可以是年轻代、老年代的一个。内存的回收是以region作为基本单位的;
- G1还有一个及其重要的特性:软实时(soft real-time)。所谓的实时垃圾回收,是指在要求的时间内完成垃圾回收。“软实时”则是指,用户可以指定垃圾回收时间的限时,G1会努力在这个时限内完成垃圾回收,但是G1并不担保每次都能在这个时限内完成垃圾回收。通过设定一个合理的目标,可以让达到90%以上的垃圾回收时间都在这个时限内。
二、类加载阶段(重要)
1、加载
- 将类的字节码载入方法区(1.8后为元空间,在本地内存中)中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 field 有:
- _java_mirror 即 java 的类镜像,例如对 String 来说,它的镜像类就是 String.class,作用是把 klass 暴露给 java 使用
- _super 即父类
- _fields 即成员变量
- _methods 即方法
- _constants 即常量池
- _class_loader 即类加载器
- _vtable 虚方法表
- _itable 接口方法
- 如果这个类还有父类没有加载,先加载父类
- 加载和链接可能是交替运行的
image-20220810223332427
- instanceKlass 这样的【元数据】是存储在方法区(1.8 后的元空间内,而元空间又位于本地内存中),但 _java_mirror
是存储在堆中
- InstanceKlass和*.class(JAVA镜像类 _java_mirror)互相保存了对方的地址
- 类的对象在对象头中保存了*.class的地址。让对象可以通过其找到方法区中的instanceKlass,从而获取类的各种信息
2、链接
验证
验证类是否符合 JVM规范,安全性检查
例如:
用 UE 等支持二进制的编辑器修改 HelloWorld.class 的魔数(3.1.1),在控制台运行
准备
为 static 变量分配空间,设置默认值
- static变量在JDK 7以前是存储与instanceKlass末尾。但在JDK 7以后就存储在_java_mirror末尾了(即堆中)
- static变量在分配空间和赋值是在两个阶段完成的。分配空间在准备阶段完成,赋值在初始化阶段完成
- 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
- 如果 static 变量是 final 的,但属于引用类型(比如 new Object()),那么赋值也会在初始化阶段完成
解析
- 将常量池中的符号引用解析为直接引用
- 符号引用:仅仅是个符号,不知道这个类或者方法、属性具体在内存的哪个位置
- 直接引用:知道这个类或者方法、属性具体在内存的哪个位置
package cn.itcast.jvm.t3.load;
/**
* 解析的含义
*/
public class Load2 {
public static void main(String[] args) throws ClassNotFoundException,IOException {
ClassLoader classloader = Load2.class.getClassLoader();
// loadClass 方法不会导致类的解析和初始化
Class<?> c = classloader.loadClass("cn.itcast.jvm.t3.load.C");
// new C(); new会导致类的解析和初始化
System.in.read();
}
}
class C {
D d = new D();
}
class D {
}
3、初始化、
对类的静态变量,静态代码块执行初始化操作。
初始化即调用 ()V ,虚拟机会保证这个类的『构造方法』的线程安全
- clinit()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的
- 所以验证类是否被初始化,可以看该类的静态代码块是否被执行
发生时机:
类的初始化的懒惰的,以下情况会初始化
- main 方法所在的类,总会被首先初始化
- 首次访问这个类的静态变量或静态方法时
- 子类初始化,如果父类还没初始化,会引发
- 子类访问父类的静态变量,只会触发父类的初始化
- Class.forName
- new 会导致初始化
以下情况不会初始化
- 访问类的 static final 静态常量(基本类型和字符串)
- 类对象.class 不会触发初始化
- 创建该类对象的数组
- 类加载器的.loadClass方法
- Class.forNamed的参数2为false时
如下代码验证
package cn.itcast.jvm.t3.load;
import java.io.IOException;
public class Load3 {
static {
System.out.println("main init");
}
public static void main(String[] args) throws ClassNotFoundException, IOException {
// // 1. 静态常量不会触发初始化
// System.out.println(B.b);
// // 2. 类对象.class 不会触发初始化
// System.out.println(B.class);
// // 3. 创建该类的数组不会触发初始化
// System.out.println(new B[0]);
// 4. 不会初始化类 B,但会加载 B、A
ClassLoader cl = Thread.currentThread().getContextClassLoader();
cl.loadClass("cn.itcast.jvm.t3.load.B");
// // 5. 不会初始化类 B,但会加载 B、A
// ClassLoader c2 = Thread.currentThread().getContextClassLoader();
// Class.forName("cn.itcast.jvm.t3.load.B", false, c2);
System.in.read();
// // 1. 首次访问这个类的静态变量或静态方法时
// System.out.println(A.a);
// // 2. 子类初始化,如果父类还没初始化,会引发
// System.out.println(B.c);
// // 3. 子类访问父类静态变量,只触发父类初始化
// System.out.println(B.a);
// // 4. 会初始化类 B,并先初始化类 A
// Class.forName("cn.itcast.jvm.t3.load.B");
}
}
class A {
static int a = 0;
static {
System.out.println("a init");
}
}
class B extends A {
final static double b = 5.0;
static boolean c = false;
static {
System.out.println("b init");
}
}
三、类加载器
1、类与类加载器
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等
以JDK 8为例
名称 | 加载的类 | 说明 |
Bootstrap ClassLoader(启动类加载器) | JAVA_HOME/jre/lib | 无法直接访问 |
Extension ClassLoader(拓展类加载器) | JAVA_HOME/jre/lib/ext | 上级为Bootstrap,显示为null |
Application ClassLoader(应用程序类加载器) | classpath | 上级为Extension |
自定义类加载器 | 自定义 | 上级为Application |
- 各司其职,每个加载器只加载自己负责目录下的所有的类
- 层级关系:
- 自底向上询问有没有加载过,例如String类
- 自定义类加载器 问 应用程序类加载器有没有加载String,如果没有,继续往上,到达启动类加载器中已经加载过了,则String不用再加载
- 如果都没有加载过则由最顶级开始往下,查找自己负责的目录下能不能加载;例如自定义的Student类
- 先往上询问,肯定都没有加载过,然后再一步步下来到应用程序加载器
2、启动类加载器
可通过在控制台输入指令,使得自定义类被启动类加器加载
在正确的路径下执行:java -Xbootclasspath/a:.cn.itcast.jvm.t3.load.Load5
3、扩展类加载类
如果classpath和 JAVA_HOME/jre/lib/ext 下有同名类,加载时会使用拓展类加载器加载。当应用程序类加载器发现拓展类加载器已将该同名类加载过了,则不会再次加载
4、自定义加载器
使用场景
- 想加载非 classpath 随意路径中的类文件
- 通过接口来使用实现,希望解耦时,常用在框架设计
- 这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器
步骤
- 继承ClassLoader父类
- 要遵从双亲委派机制,重写 findClass 方法
- 不是重写loadClass方法,否则不会走双亲委派机制
- 读取类文件的字节码
- 调用父类的 defineClass 方法来加载类
- 使用者调用该类加载器的 loadClass 方法
5、双亲委派模式
所谓的双亲委派,就是指调用类加载器的 loadClass 方法时,查找类的规则
注意
这里的双亲,翻译为上级似乎更为合适,因为它们并没有继承关系
loadClass源码
递归查找
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先查找该类是否已经被该类加载器加载过了
Class<?> c = findLoadedClass(name);
//如果没有被加载过
if (c == null) {
long t0 = System.nanoTime();
try {
//看是否被它的上级加载器加载过了 Extension的上级是Bootstarp,但它显示为null
if (parent != null) {
//有上级,就委派上级 这里是递归
c = parent.loadClass(name, false);
} else {
//如果没有上级了(ExtClassLoader),则委派BootstrapClassLoader 看是否被启动类加载器加载过
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
//捕获异常,但不做任何处理
}
if (c == null) {
//如果还是没有找到,先让拓展类加载器调用findClass方法去找到该类,如果还是没找到,就抛出异常
long t1 = System.nanoTime();
c = findClass(name);
// 记录时间
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
为了防止内存中出现多个相同的字节码;因为如果没有双亲委派的话,用户就可以自己定义一个java.lang.String类,那么就无法保证类的唯一性。
四、Java内存模型(JMM)
1、介绍
JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(也叫工作内存),本地内存中存储了该线程共享变量的副本。
多线程对共享变量进行读/写操作,只会在自己的工作内存中,对共享变量副本进行读/写操作,所以各个线程间不互通。
从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:
- 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
- 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。
所以,A修改值后,B线程不能是立刻可见,具有延迟性。
只有对共享变量设置为可见性(使用volatile 修饰),多线程对共享变量只会在主内存中进行读写操作,不会再去工作内存操作共享变量副本。只有变量被修改,其他线程立刻可见,从而实现各个线程间通信。
2、JVM对Java内存模型的实现
在JVM内部,Java内存模型把内存分成了两部分:线程栈区和堆区,下图展示了Java内存模型在JVM中的逻辑视图:
线程栈:JVM中运行的每个线程都拥有自己的线程栈。包含了当前线程执行的方法调用相关信息、当前方法的所有本地变量信息。
一个线程只能读取自己的线程栈,也就是说,线程中的本地变量对其它线程是不可见的。即使两个线程执行的是同一段代码,它们也会各自在自己的线程栈中创建本地变量,因此,每个线程中的本地变量都会有自己的版本。
堆区:包含了Java应用创建的所有对象信息,不管对象是哪个线程创建的,其中的对象包括原始类型的封装类(如Byte、Integer、Long等等)。不管对象是属于一个成员变量还是方法中的本地变量,它都会被存储在堆区。
多线程各个线程创建的对象也存储在堆区中。
下图展示了调用栈和本地变量都存储在栈区,对象都存储在堆区:
一个本地变量如果是原始类型,那么它会被完全存储到栈区。
一个本地变量也有可能是一个对象的引用,这种情况下,这个本地引用会被存储到栈中,但是对象本身仍然存储在堆区。
对于一个对象的成员方法,这些方法中包含本地变量,仍需要存储在栈区,即使它们所属的对象在堆区。
对于一个对象的成员变量,不管它是原始类型还是包装类型,都会被存储到堆区。
Static类型的变量以及类本身相关信息都会随着类本身存储在堆区。
堆中的对象可以被多线程共享。如果一个线程获得一个对象的应用,它便可访问这个对象的成员变量。如果两个线程同时调用了同一个对象的同一个方法,那么这两个线程便可同时访问这个对象的成员变量,但是对于本地变量,每个线程都会拷贝一份到自己的线程栈中。
下图展示了上面描述的过程: