总结(这四个模块都是重点!):
模块一:运行时数据区域
模块二:一个对象从出生到死亡经历了什么
对象的创建->对象的访问->分代回收算法(重点!)->对象"死亡"的条件
模块三:垃圾回收器
模块四:双亲委派模型
一、运行时数据区域
运行时数据区域:(p39)
1、程序计数器(线程隔离):当前线程所执行的字节码的行号指示器,通过改变计数器的值来确定下一条指令,比如循环,分支,跳转,异常处理,线程恢复等都是依赖计数器来完成。
2、栈区:
栈分为java虚拟机栈和本地方法栈
java虚拟机栈(线程隔离):每个方法在运行时都会创建一个栈帧,栈帧存储局部变量表、操作数栈、动态链接、方法出口等信息。其中最关键的局部变量表(我们通常所说的栈)存着编译期可知的各种基本数据类型(boolean,int)、对象引用等。
本地方法栈(线程隔离):这部分主要与虚拟机用到的 Native 方法相关,一般情况下, Java 应用程序员并不需要关心这部分的内容。
3、堆区(线程共享):唯一的目的就是存放对象实例。
java堆是gc的主要区域,通常情况下分为新生代和老年代。更细致分为:Eden、From Survivor、To Survivor空间。
线程共享的java堆中可能划分出多个线程隔离的分配缓冲区
4、方法区(线程共享):用于存放已被虚拟机加载的类信息,常量(常量池中),静态变量等数据。
被Java虚拟机描述为堆的一个逻辑部分。也被称为“永生代”(permanment generation)
运行时常量池:是方法区的一部分,用于存放编译器生成的各种符号引用,这部分内容将在类加载后放到方法区的运行时常量池中。
注:JDK1.7中,存储在永久代的部分数据就已经转移到了堆中,而到了JDK 1.8 ,使用元空间取代了永生代。
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
堆溢出:java.lang.OutOfMemoryError: Java heap space
public class Test{
public static void main(String[] args){
ArrayList list=new ArrayList();
while(true){
list.add(new Test());
}
}
}
栈溢出:java.lang.StackOverflowError
public class Test{
public static void main(String[] args){
new Test().test();
}
public void test(){
test();
}
}
面试问题:
1.JVM中堆空间可以分成三个大区,新生代、老年代、永久代
2.新生代可以划分为三个区,Eden区,两个幸存区
在JVM运行时,可以通过配置以下参数改变整个JVM堆的配置比例
1.JVM运行时堆的大小
-Xms堆的最小值
-Xmx堆空间的最大值
2.新生代堆空间大小调整
-XX:NewSize新生代的最小值
-XX:MaxNewSize新生代的最大值
-XX:NewRatio设置新生代与老年代在堆空间的大小
-XX:SurvivorRatio新生代中Eden所占区域的大小
3.永久代大小调整
-XX:MaxPermSize
二、hotspot虚拟机对象
2.1 对象的创建
1.检查
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、连接和初始化过(类加载机制)。如果没有,那必须先执行相应的类加载过程。
2.分配内存
接下来将为新生对象分配内存,为对象分配内存空间的任务等同于把一块确定的大小的内存从Java堆中划分出来。
假设Java堆中内存是绝对规整的,所有用过的内存放在一遍,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把指向空闲空间的指针挪动一段与对象大小相等的距离,这个分配方式叫做“指针碰撞”
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式成为“空闲列表”
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
3. Init
执行new指令之后会接着执行Init方法,进行初始化,这样一个对象才算产生出来
2.2 对象的内存布局(p47)
对象分为对象头、实例数据和对齐填充
对象头包括两部分:
a) MarkWord:对象自身的运行时数据,包含了锁信息、GC信息以及HashCode,长度为32bit或64bit
b) KlassPointer:类型指针,即对象指向类元数据的指针,虚拟机通过这个指针来确定对象是哪个类的实例
关于MarkWord的具体细节,实际上是并发的相关内容,因此感兴趣的可以看我的另一篇博客https://blog.youkuaiyun.com/bintoYu/article/details/86527400中第五点synchronize的第5小点原理二部分。
其实只要知道包含了锁、GC相关(分代年龄和GC标记)以及HashCode就行。:
2.3 对象的访问定位(p49)
a)使用句柄访问
Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了实例和类型的指针
优势:在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改
b)使用直接指针访问
Java堆对象的布局就必须考虑如何访问类型数据的相关信息,而refreence直接存储对象的地址
优势:速度更快,节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本
三、OutOfMemoryError 异常(OOM)
出现OOM如何解决:
(1)通过参数 -XX:+HeapDumpOnOutOfMemoryError 让虚拟机在出现OOM异常的时候Dump出内存映像以便于分析。
(2)使用jmap -heap PID >>heap_20110909.log
四、垃圾收集
程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了
1.判断对象存活
4.1.1 引用计数器法
给对象添加一个引用计数器,每当由一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的
缺点: 发现不了互相引用的失效对象:
4.1.2 可达性分析算法(主流)
通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC ROOTS没有任何引用链相连时,则证明此对象时不可用的
Java语言中GC Roots的对象包括下面几种:
1.虚拟机栈(栈帧中的本地变量表)中引用的对象
2.本地方法栈(Native方法)引用的对象
3.方法区中类静态属性引用的对象
4.方法区中常量引用的对象
2.引用
强引用就是在程序代码之中普遍存在的,类似Object obj = new Object() 这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象
软引用用来描述一些还有用但并非必须的元素。在OutOfMemoryError抛出之前,会把这些对象进行回收,如果回收后还没有足够的内存才会抛出内存溢出异常
弱引用:只能生存到下一次垃圾回收发生之前
虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知
3.一个对象“死亡”需要满足三点:
① “GC Roots”不可达
② 对象中有finalize()方法,且未被虚拟机调用过(若被调用过,下一次直接gg)
③ 满足①②条件后,对象调用finalize(),且进入F-Queue中等待死亡,若在这个过程中没人捞他一手,就GG
4.垃圾收集算法
注:
新生代 ----> 复制算法,因为每次垃圾收集时都发现有大批对象死去,只有少量存活,只需要付出少量存活对象的复制成本就可以完成收集。
老年代 ---> 标记清理或者标记整理算法,因为对象存活率高、没有额外空间。
4.4.1 标记—清除算法
算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象、
不足:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清楚之后会产生大量不连续的内存碎片,下次程序运行需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
4.4.2 复制算法
他将可用内存按照容量划分为大小相等的两块,每次只使用其中的一块。当这块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。内存分配时只需按顺序分配即可。
不足:将内存缩小为原来的一半
实际中我们并不需要按照1:1比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor
当另一个Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代
4.4.3 标记整理算法
让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
4.4.4 分代收集算法 (对象从出生到死亡的一生)
a)新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor1,survivor2)区。
b) 大部分生成的对象都放在eden区(大的对象直接放入老年代)。当eden区满了时,先检查老年代最大连续可用空间 > 新生代所有对象空间?大于的话发生一次MinorGC。小于的话进入分配担保策略。
c) MinorGC的过程:
第一轮:将eden区存活对象复制到survivor1区,然后清空eden区。
第二轮:将eden区和survivor1区的存活对象复制到另一个survivor2区,然后清空eden和这个survivor1区。
下一轮:将eden区和survivor2区的存活对象复制到另一个survivor1区,然后清空eden和这个survivor2区(也就是survivor1与survivor2交换角色)。
下一轮,survivor1与survivor2交换角色进行上述类似处理。
如此循环往复。(上述回收过程称为Minor GC)。
d) 当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收。
e)Minor GC中长期存活的(默认是15岁)对象将进入老年代
f) 持久代就是方法区。
总结图:(来自视频:https://www.bilibili.com/video/BV1AZ4y147fD?p=2)
其中TLAB是ThreadLocalAllocationBuffer,详见并发的ThreadLocal部分。
5.分配担保
发生在minorGC之前,先检查老年代最大连续可用空间 > 新生代所有对象空间?
大于的话,正常步骤进行。
小于的话,可以设置XXX(某个参数)来开启分配担保,开启的话,会继续检查 老年代最大连续可用空间 > 历次晋升到老年代对象的平均大小?
大于的话,minor GC,小于的话,先Full GC。
5.5. GC是什么时候触发的
5.1 MinorGC:eden区满了。
5.2 Full GC
有如下原因可能导致Full GC:
a) 年老代(Tenured)被写满;
b) 持久代(Perm)被写满;
c) System.gc()被显式调用;
6.内存溢出和内存泄漏
内存溢出指的是内存不够了。
而内存泄漏指的是对象已经不需要了,但仍存在着引用,导致无法GC。
如果长生命周期的对象持有短生命周期的引用,就很可能会出现内存泄露。
内存泄漏最简单的例子:
public class Simple {
Object object;
public void method1(){
object = new Object();
//...其他代码
}
}
这里object实例,如果object只在method1(),其他地方不会使用,那么这就是一种内存泄露。因为当method1()方法执行完成后,object对象所分配的内存不会被释放,只有在Simple类创建的对象被释放后才会被释放。
解决方法:
1、将object作为method1()方法中的局部变量。
2、如果一定要这么写,可以改为这样:
public class Simple {
Object object;
public void method1(){
object = new Object();
//...其他代码
object = null;
}
}
7.垃圾收集器(JDK1.8默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代))
垃圾回收器的选择是一个演变的过程:
举例子:一开始只是打扫你房间里的垃圾,自己就能搞定:
几十M时 -> serial young + serial old
当打扫的从房间变成了整栋楼,加上家里人一块来打扫,也就是多线程:
几个G时 -> Ps(Parallel Scavenge) + Po(Parallel old)
当需要打扫特别大的广场时,虽然说可以再叫更多的人,但是我们知道线程之间的切换是会消耗资源的,因此不能无限地增加垃圾清理线程的数量,所以需要更换一下垃圾情理的策略:
几十个G时 -> PerNew(Parallel new) + CMS
但因为CMS不好调参,毛病很多,所以最终hotspot不采用分代算法,使用G1来解决大内存时的清理问题(之后的所有垃圾回收器都不用分代算法)。
注:当CMS的碎片特别特别多的时候(卡起来),会使用Serial Old来将整个老年代进行清理(也就是用Serial Old来擦屁股),所以一旦CMS卡起来,就会卡特别久。
注:
新生代 ----> 复制算法,因为每次垃圾收集时都发现有大批对象死去,只有少量存活,只需要付出少量存活对象的复制成本就可以完成收集。
老年代 ---> 标记清理或者标记整理算法,因为对象存活率高、没有额外空间。
具体每个垃圾回收器的介绍:
a)Serial收集器:
1、 是一个单线程的收集器
2、垃圾收集的过程中会Stop The World(服务暂停)(所有serial类和parallel类的收集器都会stop)
b)ParNew 收集器:
Serial收集器的多线程版本,除了使用了多线程进行收集之外,其余行为和Serial收集器一样
c)Parallel Scavenge
特点:它的关注点与其他收集器不同,其目标是达到一个可控制的吞吐量。
吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
d)Serial Old 收集器:
是Serial收集器的老年代版本,所以使用标记整理算法,是一个单线程收集器,
e)Parallel Old 收集器:
Parallel Old是Paraller Seavenge收集器的老年代版本,所以使用标记整理算法,同时使用多线程。
f)CMS收集器:(concurrent mark sweep)
CMS收集器是基于标记清除(mark sweep)算法实现的,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
特点:
1、基于"标记-清除"算法
2、以获取最短回收停顿时间为目标
3、并发收集
整个过程分为4个步骤
初始标记(CMS initial mark)
初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”。
并发标记(CMS concurrent mark)
并发标记阶段就是进行GC Roots Tracing的过程。(不会stop the world)
重新标记(CMS remark)
重新标记用户线程并发运行时 出现变动的 地方,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短,仍然需要“Stop The World”。
并发清除(CMS concurrent sweep)
并发清除阶段会清除对象。 (不会stop the world)
缺点:
1.CMS收集器对CPU资源非常敏感,CMS默认启动的回收线程数是(CPU数量+3)/4,
2.CMS收集器无法处理浮动垃圾
浮动垃圾:由于CMS并发清理阶段用户线程所产生的垃圾,这部分垃圾出现在重新标记之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC中再清理。这些垃圾就是“浮动垃圾”。
3.CMS是基于标记清除算法实现的(标记清除算法会产生大量空间碎片)
注:CMS及以后的垃圾回收器采用的回收算法都是基于“三色标记法”,后面会对这个算法进行介绍。
g)G1收集器:
注:关于G1收集器的详细资料请看博客:详解 JVM Garbage First(G1) 垃圾收集器,主要关注第三章的内存模型中的Region、Card、RSet、CSet。
核心思想:逻辑分代,物理不分代。
概念:
1、使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),(虽然还保留有新生代和老年代的概念)。
2、G1跟踪各个Region里面的垃圾堆积的回收价值和成本(回收所获得的空间大小及回收所需时间的经验值),在后台维护一个优先列表,按优先级回收Region
3、 同CMS,G1也可以实现gc线程和用户线程的并发。
初始标记、并发标记:同上。
最终标记(Final Marking)
作用同重新标记,同时虚拟机会将对象的变化记录在Logs里面,并且会把Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。
筛选回收(Live Data Counting and Evacuation)
筛选回收阶段首先对各个Region的回收价值和成本进行排序,按优先级回收Region。(这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率)。
8.内存分配与回收策略(了解)
4.6.1 对象优先在Eden分配:
大多数情况对象在新生代Eden区分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC
4.6.2 大对象直接进入老年代:
所谓大对象就是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。这样做的目的是避免Eden区及两个Servivor之间发生大量的内存复制
4.6.3长期存活的对象将进入老年代
如果对象在Eden区出生并且尽力过一次Minor GC后仍然存活,并且能够被Servivor容纳,将被移动到Servivor空间中,并且把对象年龄设置成为1.对象在Servivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁),就将会被晋级到老年代中
4.6.4动态对象年龄判定
为了更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋级到老年代,如果在Servivor空间中相同年龄所有对象的大小总和大于Survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入到老年代,无须登到MaxTenuringThreshold中要求的年龄
4.6.4 空间分配担保:
Minor GC 之前 ---> 老年代最大可用的连续空间 > 新生代所有对象总空间?Minor GC安全:情况A;
A:HandlePromotionFailure开启?情况B:FULL GC;
B:老年代最大可用的连续空间 > 历次晋级到老年代对象的平均大小:冒险Minor GC:FULL GC;
9. 三色标记法 (感兴趣的可以了解,不是重点)
三色标记法一共包含了黑、灰、白三种颜色,黑色指的是“自己已经标记,同时所有的引用也标记完成”,灰色指的是“自己标记完成,但引用没标记完(都没标记或标记了一部分都算灰色)”,白色指的是“没有遍历到的节点”,如下图所示:
9.1 可能出现的问题一 (浮动垃圾):
当正在标记B时,如上图的状态,此时B->D的引用消失了,那么D就会标不出来,成为浮动垃圾。
浮动垃圾问题的后果不严重,下一次回收就行。
9.2 可能出现的问题二:
B->D的引用消失的同时,新增了A->D的引用:
因为A是黑的,导致接下来的不会扫描到A->D,导致系统认为有用的D是一个垃圾而被干掉。因此这个问题导致的后果会很严重,必须解决。
9.3 CMS 对问题二的解决方法:
CMS会在新增A->D引用时,将A变回成灰色(具体实现原理是JVM会在写屏障后面进行操作),这样接下来的扫描会再次对A进行扫描。
9.4 CMS的解决方法会出现的问题:
有点类似ABA问题。
m1(垃圾回收线程) 正在标记A,已经标记完属性1,正在标记属性2。
m2(业务线程)将属性1指向白色对象D
m3(垃圾回收线程)将A标为灰色
m1(垃圾回收线程) 标记完所有属性,把A设为黑色,结果D被漏标。
因为CMS会出现这个问题,所以在重新标记阶段必须得从头开始。
9.5 G1对这一问题的解决方法: STAB (Snapshot at the begining)
SATB:主要针对标记-清除垃圾收集器的并发标记阶段,非常适合G1的分区块的堆结构,同时解决了CMS的主要烦恼:重新标记暂停时间长带来的潜在风险。
Snapshot at the begining的思想如下:
1. 有一块专门用于存储快照的地方(STAB日志或缓冲区)。
2. 当有引用发生改变时,JVM的写前屏障会在引用变更前,将引用记录在快照中。
3. 每个线程都有自己的快照区,最终在并发标记阶段,并发标记线程(Concurrent Marking Threads)在标记的同时,还会定期检查和处理快照区的记录,然后根据记录中的引用来更新RSet。
五、类加载机制(重要)
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制
5.1 类加载的时机
类的整个生命周期包括:加载、连接(验证、准备、解析)、初始化、使用和卸载7个阶段
而类的加载包括:加载、连接(验证、准备、解析)、初始化
5.2 类加载的过程(重要)
5.2.1 加载
1)通过类的全限定名查找该类的字节流文件
2)将这字节流所代表的静态存储结构转化为方法区运行时数据结构(静->动)
3)在内存中生成Class对象,作为方法区这个类的各种数据的访问入口
数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的
(数组类的创建过程遵循以下规则:
1)如果数组的组件类型(指的是数组去掉一个维度的类型)是引用类型,那就递归单个类加载过程去加载,数组C将在加载该组件类型的类加载器的类名称空间上被标识
2)如果数组的组件类型不是引用类型(列如int[]组数),Java虚拟机将会把数组C标识为与引导类加载器关联
3)数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public)
5.2.2 验证
验证阶段会完成下面4个阶段的检验动作:文件格式验证,元数据验证,字节码验证,符号引用验证
1.文件格式验证
第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,如验证魔数是否0xCAFEBABE。
这个阶段的验证是基于二进制字节流进行的,只有通过验证后,字节流才会进入内存的方法区进行存储,所以后面的3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流
2.元数据验证(即与父类的关系)
1.这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)
2.如果这个类不是抽象类,是否实现了父类或接口之中要求实现的所有方法
3.类中的字段、方法是否与父类产生矛盾
3.字节码验证
第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语言是否是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。
5.2.3 准备
为类中的所有静态变量 分配内存空间,并为其设置一个初始值。注意:这里不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
假设public static int value = 123;
那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行,但是如果使用final修饰,则在这个阶段其初始值设置为123
5.2.4解析
解析阶段是虚拟机将常量池内符号引用替换为直接引用的过程
符号引用:用一组符号来描述所引用的目标
直接引用:可以使直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
5.2.5 初始化
类的初始化阶段是类加载过程的最后一步,是执行类构造器<client>()方法的过程。
只有主动引用的时候才会触发类的初始化操作。
(一个类在初始化的时候要求其父类全部初始化了,但是一个接口初始化的时候不要求其父接口全部都初始化了)
在连接的准备阶段,类变量已赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员自己写的逻辑去初始化类变量和其他资源,举个例子如下:
public static int value1 = 5;
public static int value2 = 6;
static{
value2 = 66;
}
在准备阶段value1和value2都等于0;
在初始化阶段value1和value2分别等于5和66;
主动引用:
虚拟机规范规定有且只有5种情况(即主动引用)必须立即对类进行初始化:
1.遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候
2.使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化
3.当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
4.当虚拟机启动时候,虚拟机会先初始化包含main()方法的那个类
5.当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化
被动引用:
1.通过子类引用父类的静态字段,不会导致子类初始化
2.通过数组定义来引用类,不会触发此类的初始化
3.常量不会触发定义常量的类的初始化,因为常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类。
接口的初始化:
接口在初始化时,并不要求其父接口全部完成类初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化
5.3 类的加载器(重要)
5.3.1 类与类加载器
比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义。
5.3.2 双亲委派模型:
只存在两种不同的类加载器:启动类加载器(Bootstrap ClassLoader),使用C++实现,是虚拟机自身的一部分。另一种其他类加载器,使用JAVA实现,独立于JVM,并且全部继承自抽象类java.lang.ClassLoader.
启动类加载器(Bootstrap ClassLoader),负责将存放在<JAVA+HOME>\lib目录中的,或者被-Xbootclasspath参数所制定的路径中的,并且是JVM识别的(仅按照文件名识别,如rt.jar,如果名字不符合,即使放在lib目录中也不会被加载),加载到虚拟机内存中,启动类加载器无法被JAVA程序直接引用。
扩展类加载器,由sun.misc.Launcher$ExtClassLoader实现,负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
应用程序类加载器(Application ClassLoader),由sun.misc.Launcher$AppClassLoader来实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般称它为系统类加载器。负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
这张图表示类加载器的双亲委派模型(Parents Delegation model). 双亲委派模型要求除了顶层的启动加载类外,其余的类加载器都应当有自己的父类加载器。,这里类加载器之间的父子关系一般不会以继承的关系来实现,而是使用组合关系来复用父类加载器的代码。
双亲委派模型的工作过程是:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,夫加载器会抛出ClassNotFoundException然后给子加载器,子加载器才会尝试自己去加载。
这样做的好处就是:
1、Java类随着它的类加载器一起具备了一种带有优先级的层次关系,可以避免类的重复加载。例如类java.lang.Object,它存放在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。
2、保证安全,如果没有使用双亲委派模型,如果用户自己编写了一个java.lang.object的类,并放在ClassPath中,那系统会出现多个Object类,应用程序会变得一片混乱。
六、Java内存模型与线程
注:JAVA内存模型中所说的“变量”与java编程的变量有所不同,不包括局部变量和方法参数,因为它们是线程私有的。
6.1主内存与工作内存
Java内存模型规定所有的变量存储在主内存中,每条线程还有自己的工作内存。
6.2 内存间的交互操作
Java内存模型定义了以下八种操作来完成:(理解,不背)
lock(锁定):作用于主内存变量,把一个变量标识为一条线程独占状态。
unlock(解锁):作用于主内存变量,解锁才可以被其他线程锁定。
read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
load(载入):作用于工作内存的变量,它把read操作得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,把工作内存中的变量值传送到主内存中。(只是负责传输)
write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入到主内存的变量中。
如果要把一个变量从主内存中复制到工作内存,就需要按顺序德执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。注意,这里只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间, store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺 序是read a,read b,load b, load a。
Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:(理解)
1、不允许read和load、store和write操作之一单独出现
2、不允许一个线程丢弃它的最近的assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
3、不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
4、一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
5、一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
6、如果对一个变量执行lock操作,将会清空工作内存中此变量的值
7、如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
8、对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
6.3 对于volatile型变量的特殊规则
当一个变量定义为volatile之后,它将具备两种特性:
第一:保证此变量对所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值,其他线程可以立即得知新值。普通变量的值在线程间传递需要通过主内存来完成。
由于valatile只能保证可见性,在不符合以下两条规则的运算场景中,我们仍要通过加锁来保证原子性
1.运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
2.变量不需要与其他的状态变量共同参与不变约束
第二:禁止指令重排序,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中执行顺序一致,这个就是所谓的线程内表现为串行的语义
Java内存模型中对volatile变量定义的特殊规则。假定T表示一个线程,V和W分别表示两个volatile变量,那么在进行read、load、use、assign、store、write操作时需要满足如下的规则:
1.只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且,只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行load操作。线程T对变量V的use操作可以认为是与线程T对变量V的load和read操作相关联的,必须一起连续出现。这条规则要求在工作内存中,每次使用变量V之前都必须先从主内存刷新最新值,用于保证能看到其它线程对变量V所作的修改后的值。
2.只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store操作;并且,只有当线程T对变量V执行的后一个动作是store操作的时候,线程T才能对变量V执行assign操作。线程T对变量V的assign操作可以认为是与线程T对变量V的store和write操作相关联的,必须一起连续出现。这一条规则要求在工作内存中,每次修改V后都必须立即同步回主内存中,用于保证其它线程可以看到自己对变量V的修改。
3.假定操作A是线程T对变量V实施的use或assign动作,假定操作F是操作A相关联的load或store操作,假定操作P是与操作F相应的对变量V的read或write操作;类型地,假定动作B是线程T对变量W实施的use或assign动作,假定操作G是操作B相关联的load或store操作,假定操作Q是与操作G相应的对变量V的read或write操作。如果A先于B,那么P先于Q。这条规则要求valitile修改的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同。
6.4 对于long和double型变量的特殊规则
Java模型要求lock、unlock、read、load、assign、use、store、write这8个操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这4个操作的原子性
6.5 原子性、可见性和有序性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性,valatile特殊规则保障新值可以立即同步到主内存中。Synchronized是在对一个变量执行unlock之前,必须把变量同步回主内存中(执行store、write操作)。被final修饰的字段在构造器中一旦初始化完成,并且构造器没有吧this的引用传递出去,那在其他线程中就能看见final字段的值
可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
有序性:即程序执行的顺序按照代码的先后顺序执行。
6.6 先行发生原则
这些先行发生关系无须任何同步就已经存在,如果不在此列就不能保障顺序性,虚拟机就可以对它们任意地进行重排序
1.程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确的说,应该是控制顺序而不是程序代码顺序,因为要考虑分支。循环等结构
2.管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而后面的是指时间上的先后顺序
3.Volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的后面同样是指时间上的先后顺序
4.线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作
5.线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.joke()方法结束、ThradisAlive()的返回值等手段检测到线程已经终止执行
6.线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断时间的发生,可以通过Thread.interrupted()方法检测到是否有中断发生
7.对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始
8.传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论
6.7 Java线程调度
协同式调度:线程的执行时间由线程本身控制
抢占式调度:线程的执行时间由系统来分配
6.8 状态转换
1.新建
2.运行:可能正在执行。可能正在等待CPU为它分配执行时间
3.无限期等待:不会被分配CUP执行时间,它们要等待被其他线程显式唤醒
4.限期等待:不会被分配CUP执行时间,它们无须等待被其他线程显式唤醒,一定时间会由系统自动唤醒
5.阻塞:阻塞状态在等待这获取到一个排他锁,这个时间将在另一个线程放弃这个锁的时候发生;等待状态就是在等待一段时间,或者唤醒动作的发生
6.结束:已终止线程的线程状态,线程已经结束执行
六、线程安全(了解)
1、不可变:不可变的对象一定是线程安全的、无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障。例如:把对象中带有状态的变量都声明为final,这样在构造函数结束之后,它就是不可变的。
2、绝对线程安全:非常严格的定义,因此很难实现。
3、相对线程安全:相对的线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性
4、线程兼容:对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全使用
5、线程对立:是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码
6.1 线程安全的实现方法
1.互斥同步:
同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。互斥是因,同步是果:互斥是方法,同步是目的
在Java中,最基本的互斥同步手段就是synchronized关键字,它经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;如果没有指明,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,对应的在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,哪当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止
相比Synchronized,ReentrantLock增加了一些高级功能
1.等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助
2.公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;非公平锁则不能保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。Synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁
3.锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多余一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无须这样做,只需要多次调用newCondition方法即可
2.非阻塞同步
3.无同步方案
可重入代码:也叫纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身)而在控制权返回后,原来的程序不会出现任何错误。所有的可重入代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。
判断一个代码是否具备可重入性:如果一个方法,它的返回结果是可预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的
线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保障,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题
6.2锁优化
适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁
7.2.1 自旋锁与自适应自旋
自旋锁:如果物理机器上有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程稍等一下,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁
自适应自旋转:是由前一次在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自过程,以避免浪费处理器资源。
7.2.2 锁消除
锁消除是指虚拟机即时编辑器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。如果在一段代码中。推上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行
7.2.3锁粗化
如果虚拟机检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部
7.2.4 轻量级锁
7.2.5 偏向锁
它的目的是消除无竞争情况下的同步原语,进一步提高程序的运行性能。如果轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把这个同步都消除掉,CAS操作都不做了
如果在接下俩的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要在进行同步
七、类文件结构(优先级低)
1 概述
Java虚拟机不和包括Java在内的任何语言绑定,只与 "Class文件" 这种特定的二进制文件所关联。
2 Class类文件结构
Class文件是一组以8位字节为基础的二进制流。当遇到需要占用8位字节以上空间时,则会按照高位在前的方式分割成若干个8位字节进行存储。
Class文件格式采用一种类似于C语言结构体的伪结构来存储,这种伪结构有两种数据类型:无符号数和表。
这里需要重复提一下,Class文件结构不像XML等描述语言,由于它没有任何分割符号,所以无论是数量甚至于数据存储的字节序这样的细节都被严格限定。
2.1 魔数与Class文件版本
每个Class文件的头四个字节称为魔数(Magic Number),它起到校验的作用,也就是确定这个文件是否能被虚拟机接受。紧接着5-8字节存储的是Class文件的版本号:5-6是次版本号,7-8是主版本号。
2.2 常量池
紧接着主次版本号之后的是常量池入口,常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目。常量池主要存放两大常量:字面量和符号引用。字面量比较接近于java语言层面的的常量概念,如文本字符串、声明为final的常量值等。而符号引用则属于编译原理方面的概念。包括下面三类常量:
-
类和接口的全限定名
-
字段的名称和描述符
-
方法的名称和描述符
2.3访问标志
在常量池结束之后,紧接着的两个字节代表访问标志,表示这个Class是类还是接口,是否为public或者abstract类型等。具体标志位及标志的含义如下图所示:
2.4 类索引、父类索引与接口索引集合
类索引、父类索引与接口索引集合都按顺序排列在访问标志之后,Class文件由这三项数据来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于java语言的单继承,所以父类索引只有一个,除了java.lang.Object之外,所有的java类都有父类,因此除了java.lang.Object外,所有java类的父类索引都不为0。接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按implents(如果这个类本身是接口的话则是extends)后的接口顺序从左到右排列在接口索引集合中。
2.5 字段表集合
字段表(field info)用于描述接口或类中声明的变量。字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。
2.6方法表集合(随便看看)
与字段表几乎采用了完全一致的方式。因为volatile修饰符和transient修饰符不可以修饰方法,所以方法表的访问标志中没有这两个对应的标志,但是增加了synchronized、native、abstract等关键字修饰方法,所以也就多了这些关键字对应的标志。
2.7 属性表结合
3 字节码指令简介
3.1字节码与数据类型
在java虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息,例如iload指令用于从局部变量表中加载int类型的数据到操作数栈中,而fload指令加载的则是float类型的数据。这两条指令的操作在虚拟机内部可能是同一段代码实现的,但在Class文件中它们必须拥有各自独立的操作码。
大部分的指令都没有支持整数类型byte、char、short、boolean类型。实际上都是使用相应的int作为运算符类型。
3.2 加载和存储指令
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。
3.3 运算指令
运算或算术指令用于对操作数栈上的值进行某种特定运算,并把结果重新存入操作栈顶。 大体上算术指令可以分为两种:对整型数据和对浮点数据进行运算指令。(由于没有byte、char、short、boolean类型,所以对这类数据的运算应使用int类型指令代替)
3.4 类型转换指令
类型转换指令可以将两种不同的数值类型进行相互转换。(比如int类型转换为float类型) 小范围到大范围类型安全转换,无需显式的转换指令,否则必须显式的使用转换指令来完成。
3.5 对象创建与访问指令
虽然类实例和数组都是对象,但java虚拟机对类实例和数组的创建和操作使用了不同的字节码指令。
3.6 操作数栈管理指令
如同操作数据结构中的栈一样,java虚拟机也提供了一些用于直接操作操作数栈的指令。
3.7 控制转移指令
可以认为控制转移指令就是在有条件或无条件地修改PC寄存器的值。
3.8 方法调用和返回指令
-
invokevirtual 指令用于调用对象的实例方法
-
invokeinterface指令用于调用接口方法
-
invokespecial指令用于调用一些需要特殊处理的实例方法
-
invokestatic指令用于调用static方法
-
invokedynamic指令用于在运行时动态解析出调用点限定符所使用的方法。
方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的。
3.9 异常处理指令
在java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的,而是采用异常表的方式。
3.10 同步指令
java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构使用管程(Monitor)来支持的。
八、逃逸分析(了解)
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,成为方法逃逸。甚至还可能被外部线程访问到,比如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸
如果一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化
栈上分配:如果确定一个对象不会逃逸出方法外,那让这个对象在栈上分配内存将会是一个不错的注意,对象所占用的内存空间就可以随栈帧出栈而销毁。如果能使用栈上分配,那大量的对象就随着方法的结束而销毁了,垃圾收集系统的压力将会小很多
同步消除:如果确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉
标量替换:标量就是指一个数据无法在分解成更小的数据表示了,int、long等及refrence类型等都不能在进一步分解,它们称为标量。
如果一个数据可以继续分解,就称为聚合量,Java中的对象就是最典型的聚合量
如果一个对象不会被外部访问,并且这个对象可以被拆散的化,那程序正整执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替