1. 运行时数据区域
- Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。
- 线程私有的:虚拟机栈,本地方法栈,程序计数器
- 线程共享的 方法区,堆
程序计数器
- 程序计数器是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码行号指示器,在虚拟机的概念模型里,字节码解释器工作时 就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等基础功能都需要这个计数器来完成。(如果正在执行的是本地方法则计数器为空)。
Java虚拟机栈
- 虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会创建一个栈帧用于存储局部变量表,操作栈,动态链接,方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。
本地方法栈
本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务。
Java 堆
Java
堆是整个虚拟机所管理的最大内存区域,所有的对象创建都是在这个区域进行内存分配。- 这块区域也是垃圾回收器重点管理的区域,由于大多数垃圾回收器都采用
分代回收算法
,所有堆内存也分为新生代
、老年代
,可以方便垃圾的准确回收。
方法区
- 方法区主要用于存放已经被虚拟机加载的类信息,如
常量,静态变量
,即时编译器编译后的代码等。和Java堆一样不需要连续的内存,并且可以动态扩展。 - 对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。
运行时常量池
- 运行时常量池是方法区的一部分。class文件除了有类的版本,字段,方法,接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,会在类加载后放入这个区域。
直接内存
- 直接内存并不是虚拟机运行时数据区域的一部分。
- 在 JDK 1.4 中新加入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过 Java堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在堆内存和堆外内存来回拷贝数据。
2. Minor GC和Full GC
- Minor GC:指发生在新生代的垃圾收集动作,因为 Java 对象大多都具
备朝生夕灭的特性,所以Minor GC
非常频繁,一般回收速度也比较快。 - Major GC或Full GC:指发生在老年代的 GC,出现了 Major GC,经常
会伴随至少一次的 Minor GC(但非绝对的,在 ParallelScavenge 收集器的收集策略里
就有直接进行Major GC
的策略选择过程) 。MajorGC
的速度一般会比 Minor GC 慢 10
倍以上。
Minor GC触发机制
当年轻代满时就会触发Minor GC,这里的年轻代满指的是Eden代满,Survivor满不会引发GC
Full GC触发机制:
- 当年老代满时会引发Full GC,Full GC将会同时回收年轻代、年老代,
- 当永久代满时也会引发Full GC,会导致Class、Method元信息的卸载
3. Java中的四种引用
强引用,软引用,弱引用,虚引用
强引用
就是指在程序代码中普遍存在的,类似Object obj=new Object()这类的引用,只要强引用还存在,垃圾回收期永远不会回收掉被引用的对象
软引用
用来描述一些还有用,但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出前,将会把这些对象列进回收范围之内并进行第二次回收,如果这此次回收还是没有足够的内存,才会抛出内存溢出。
弱引用
用来描述非必须的对象,但是它的强度比软引用更弱一下,被弱引用关联的对象,只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,只会回收被弱引用关联的对象
虚引用
被称为幽灵引用或幻引用,是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其它生存时间构成影响,也无法通过虚引用来取得一个实列。为一个对象设置虚引用的目的就是在对象被回收时收到一个系统通知。
4. 垃圾收集算法
Serial收集器
- 一个单线程的收集器,只会使用一个CPU或一条收集线程去完成垃圾收集工作。在进行垃圾收集时必须暂停其它所有的工作线程,直接到结束。(Stop The Word)这项工作是虚拟机在后台自动发起和完成的。
- JDK1.3之前是新生代收集的唯一选择。
- 它依然是虚拟机运行在Client模式下的默认新手代收集器,简单而高效。
ParNew收集器
Serial收集器的多线程版本,使用多条线程收集。其余的和Serial一样,是许多运行在Server模式下的虚拟机首选新生代收集器。且目前除了Serial收集器,只有它可以与CMS收集器配合工作
3.Parallel Scavenge收集器
- 它是一款新生代收集器。使用复制算法收集,又是并行的多线程收集器
- 特点是达到一个可控制的吞吐量,也被称为“吞吐量优先”收集器。
Serial Old收集器
- 它是Serial收集器的老年代版本,是一个单线程收集器,使用标记-整理算法收集。
- 主要意义是给Client模式下虚拟机使用。如果是Server模式,则有两种用途,一是在JDK1.5之前与Parallel Scavenge收集器搭配使用。二是作为CMS收集器的后背预案
Parallel Old收集器
它是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法。JDK1.6才开始提供。
CMS收集器
- 是一种以获取最短回收停顿时间的为目标的收集器。基于标记-清楚算法实现。
- 运作过程分为四个阶段。初始标记,并发标记,重新标记,并发清除。
- 初始标记和并发标记仍然需要”Stop The Word”.初始标记只是记录下GC Roots能直接关联到对象,速度快。并发标记就是进行GC Roots Tracing过程。重新标记修正并发标记期间因程序继续运作导致标记产生变动的一部分对象的标记记录。整个过程耗时最长是并发标记和并发清除过程。
- 优点是并发收集,低停顿。缺点是:对CPU资源非常敏感,无法处理浮动垃圾。收集结束时会产生大量空间碎片
G1收集器
- 当前收集器技术最前沿成果之一。将整个Java堆分为多个大小相等的独立区域。虽然保留新生代和老年代,但它们不再是物理隔离,都是一部分不需要连续的集合。
- 特点是并行与并发充分利用CPU缩短停顿时间。分代收集,空间整合不会产生内存空间碎片,可预测的停顿。有计划的避免回收整个Java堆。
- 运行大致分为:初始标记,并发标记,最终标记,筛选回收。
标记-清除算法
算法分为标记和清除两个阶段。首先先标记所有要被回收的对象,标记完成后再统一清除被标记的对象。
主要缺点有两个,
一是效率问题,标记和清除的过程效率都不高。二是空间问题,标记清除后会产生大量不连续的内存碎片,空间碎片太多,可能会导致,当程序在以后的运行过程中需要分配较大的对象时无法找到足够的连续内存,而不得不提前出发另一次垃圾收集动作
复制算法
- 为了解决效率问题,一种复制收集的算法出现了。它将可用内存按容量划分为大小相等的两块,每次只用其中的一块。当这一块内存用完,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对其中一块进行内存回收,内存分配时也就不用内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半未免太高了一点。
标记-整理算法
- 复制手机算法在对象存活率较高的时要执行多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要额外的空间进行分配担保,以应对被使用的内存中对象都100%存货的极端情况,所以在老年代一般不能直接选用这种算法。根据老年代的特点,有人提出了另一种 标记-整理的算法,标记过程仍然与 标记-清楚算法一样。但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存
分代收集算法
- 根据对象的存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高,没有额外空间对它进行分配担保,就必须使用标记-清理或标记-整理算法来进行回收
5. 内存分配与回收策略
- 对象的内存分配,往大方向讲,就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动本地线程分配缓冲,将按线程的优先级在TLAB上分配。少数情况也可能分配在老年代中,分配的规则并不是百分之白固定,其细节取决于当前使用的是哪一种垃圾回收期组合,还有虚拟机中于内存相关的参数设置。
对象优先在Eden区分配
对象通常在新生代的Eden区进行分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,与Minor GC对应的是Major GC、Full GC。
- Minor GC:指发生在新生代的垃圾收集动作,非常频繁,速度较快。
- Major GC:指发生在老年代的GC,出现Major GC,经常会伴随一次Minor GC,同时Minor GC也会引起Major GC,一般在GC日志中统称为GC,不频繁。
- Full GC:指发生在老年代和新生代的GC,速度很慢,需要Stop The World。
大对象直接进入老年代
- 需要大量连续内存空间的Java对象称为大对象,大对象的出现会导致提前触发垃圾收集以获取更大的连续的空间来进行大对象的分配。虚拟机提供了-XX:PretenureSizeThreadshold参数来设置大对象的阈值,超过阈值的对象直接分配到老年代。
长期存活的对象进入老年代
- 每个对象有一个对象年龄计数器,与前面的对象的存储布局中的GC分代年龄对应。对象出生在Eden区、经过一次Minor GC后仍然存活,并能够被Survivor容纳,设置年龄为1,对象在Survivor区每次经过一次Minor GC,年龄就加1,当年龄达到一定程度(默认15),就晋升到老年代,虚拟机提供了-XX:MaxTenuringThreshold来进行设置。
动态对象年龄判断
- 对象的年龄到达了MaxTenuringThreshold可以进入老年代,同时,如果在survivor区中相同年龄所有对象大小的总和大于survivor区的一半,年龄大于等于该年龄的对象就可以直接进入老年代。无需等到MaxTenuringThreshold中要求的年龄。
具体代码如下:
1 | public class AllocationTest { |
空间分配担保
- 发生Minor GC时,虚拟机会检查老年代连续的空闲区域是否大于新生代所有对象的总和,若成立,则说明Minor GC是安全的,否则,虚拟机需要查看HandlePromotionFailure的值,看是否运行担保失败,若允许,则虚拟机继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,若大于,将尝试进行一次Minor GC;若小于或者HandlePromotionFailure设置不运行冒险,那么此时将改成一次Full GC,以上是JDK Update 24之前的策略,之后的策略改变了,只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。
- 冒险是指经过一次Minor GC后有大量对象存活,而新生代的survivor区很小,放不下这些大量存活的对象,所以需要老年代进行分配担保,把survivor区无法容纳的对象直接进入老年代。
回收方法区
- 很多人任务方法区是没有垃圾回收的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而在方法去进行垃圾收集的性价比一般比较低,在堆中,由其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%~96%的空间,而永久代的垃圾收集效率远低于此。
- 永久代的垃圾主要回收两部分内容:废弃常量和无用的类。
- 回收废弃常量于回收
Java堆
中的对象非常相似。以常量池中字面量的回收为列,假如一个字符串“abc
“已经进入常量池中,但是当前系统没有任何一个String对象叫做”abc
“的,换句话就是没有任何Sting对象引用常量池中的”abc”,也没有其它地方引用了这个字面变量,如果这时候发生内存回收,而且必要的话,这个“abc
“常量就会被系统请出常量池,常量池中的其它类,接口,方法,字段的符号引用也与此类似。
Java中对象访问是如何进行的
对象访问在Java中无处不在,即时是最简单的访问也会涉及到Java栈,Java堆,方法区这三个最重要的内存区域之间的关系。
1
Object obj=new Object();
假设这段代码出现在方法体中, 那吗“
Object obj
”这部分的语义将会反应到Java栈
的本地变量中,作为一个reference
类型数据出现。而“new Object()
”这部分的语义将会反应到Java堆
中,形成一块存储了Object类型所有实例数据值的结构化内存,根据具体类型以及虚拟机实现的对象内存布局的不同,这块内存的长度是不固定的。另外,在Java堆中还必须包含能查找到此对象类型数据(如对象类型,父亲,实现的接口,方法等)的地址消息,这些类型数据则存储在方法区中。
怎样判断对象是否存活
- 是否使用引用计数法?很多判断对象存活的算法是这样的,给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器减1;
- 任何时刻计数器都为0的对象就是不可能再被使用的。客观的来说,引用计数法的实现简单,判定效率也很高,在大部分情况下是一个不错的算法,也有一些著名的案例,列如微软的COM技术,但是,在Java语言中没有选用引用技术发来管理内存,其中最主要的原因是因为它很难解决对象之间的互循环引用问题。
摘抄自<<深入理解Java虚拟机>>一书中的原话
- 根搜索算法:Java是使用根搜索算法判断对象是否存活的。
- 这个算法的思路就是通过一系列的名为“GC roots”的对象作为起点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象的GC roots没有任何引用链相连时,则证明此对象是不可用的。如下图所示,对象object5,object6,object7虽然相互关联,但是他们的GC roots是不可达到的,所以它们将会被判定是可回收的对象。
作为GC roots的几种对象
- 虚拟机栈(栈中的本地变量表)中的引用对象。
- 方法区中的类静态属性引用对象。
- 方法区中的常量引用的对象。
- 本地方法中JNI(即一般说的native方法)的引用的对象。
6. 虚拟机类加载机制
类加载的时机
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中准备、验证、解析3个部分统称为连接(Linking)
加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。
何时开始类加载的第一个阶段
- java虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行初始化(而加载,验证,准备自然需要再次之前开始)
- 遇到
new,getstatic,pustatic
或invokestatic
这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见Java代码场景是:使用new关键字实例化对象,读取或设置一个类的静态字段(被final修饰,已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。 - 对类进行反射调用时,如果类没有进行过初始化,则需要先触发其初始化。
- 当初始化一个类时,如果发现父类还没有初始化,则需要先触发父类初始化。
- 当虚拟机启动时,用户指定一个执行的主类,虚拟机会先初始化这个主类。
- 当使用jdk1.7动态语言支持时,如果一个实例最后解析结果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
什么是被动引用
一个类进行主动引用时会执行初始化。所有引用类的方式多不会触发初始化称为被动引用。
- 通过子类调用父类的静态字段,不会导致子类初始化,只会触发父类的初始化。
- 通过数组定义的引用类,不会触发此类的初始化。
- 常量在编译阶段会存入调用类的常量池中,因此不会触发定义常量的类初始化。
类的加载过程
1.加载
在加载阶段(可以参考java.lang.ClassLoader的loadClass()方法),虚拟机需要完成以下3件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流(并没有指明要从一个Class文件中获取,可以从其他渠道,譬如:网络、动态生成、数据库等);
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口;
加载阶段和连接阶段(Linking)的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
2.验证
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
验证阶段大致会完成4个阶段的检验动作:
- 文件格式验证:验证字节流是否符合Class文件格式的规范;例如:是否以魔术0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
- 元数据验证:对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如:这个类是否有父类,除了java.lang.Object之外。
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的
- 符号引用验证:确保解析动作能正确执行。
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
3.准备
- 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:
1 | public static int value=123; |
- 那变量value在准备阶段过后的初始值为0而不是123.因为这时候尚未开始执行任何java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。
- 至于“特殊情况”是指:public static final int value=123,即当类字段的字段属性是ConstantValue时,会在准备阶段初始化为指定的值,所以标注为final之后,value的值在准备阶段初始化为123而非0.
4.解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
5.初始化
- 如果一个类被主动引用,就会触发类的初始化。
- 在java中,直接引用的情况有,通过new关键字实例化对象、读取或设置类的静态变量、调用类的静态方法。通过反射方式执行以上三种行为。初始化子类的时候,会触发父类的初始化。作为程序入口直接运行时(也就是直接调用main方法)。除了以上四种情况,其他使用类的方式叫做被动引用,而被动引用不会触发类的初始化
6.使用
- 类的使用包括主动引用和被动引用
- 被动引用:引用父类的静态字段,只会引起父类的初始化,而不会引起子类的初始化。定义类数组,不会引起类的初始化。引用类的常量,不会引起类的初始化。
7.卸载
- 满足下面的情况,类就会被卸载:该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例。加载该类的ClassLoader已经被回收。该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
- 如果以上三个条件全部满足,jvm就会在方法区垃圾回收的时候对类进行卸载,类的卸载过程其实就是在方法区中清空类信息,java类的整个生命周期就结束了。
总结
- 对象基本上都是在jvm的堆区中创建,在创建对象之前,会触发类加载(加载、连接、初始化),当类初始化完成后,根据类信息在堆区中实例化类对象,初始化非静态变量、非静态代码以及默认构造方法,当对象使用完之后会在合适的时候被jvm垃圾收集器回收。
- 对象的生命周期只是类的生命周期中使用阶段的主动引用的一种情况(即实例化类对象)。而类的整个生命周期则要比对象的生命周期长的多。
类的生命周期
- jvm(java虚拟机)中的几个比较重要的内存区域,这几个区域在java类的生命周期中扮演着比较重要的角色:
- 方法区:在java的虚拟机中有一块专门用来存放已经加载的类信息、常量、静态变量以及方法代码的内存区域,叫做方法区。
- 常量池:常量池是方法区的一部分,主要用来存放常量和类中的符号引用等信息。
- 堆区:用于存放类的对象实例。
- 栈区:也叫java虚拟机栈,是由一个一个的栈帧组成的后进先出的栈式结构,栈桢中存放方法运行时产生的局部变量、方法出口等信息。当调用一个方法时,虚拟机栈中就会创建一个栈帧存放这些数据,当方法调用完成时,栈帧消失,如果方法中调用了其他方法,则继续在栈顶创建新的栈桢。
当我们编写一个java的源文件后,经过编译会生成一个后缀名为class的文件,这种文件叫做字节码文件,只有这种字节码文件才能够在java虚拟机中运行,java类的生命周期就是指一个class文件从加载到卸载的全过程
一个java类的完整的生命周期会经历加载、连接、初始化、使用、和卸载五个阶段,当然也有在加载或者连接之后没有被初始化就直接被使用的情况
![](https://gitee.com/uploads/images/2018/0704/125820_a13e503b_1478371.png “
类加载器
- 通过一个类的全限定名来获取描述此类的二进制字节流,这个动作放到java虚拟机外部去实现。以便让应用程序自己决定如何去获取所需要的类。实现各动作的代码模块称为“类加载器”。
- 比较两个类是否相等,只有这两个类是由同一个类加载器加载的前提下才有意义,否则即使这两个;诶是来源同一个class文件,但类加载器不同,他们也不相等。
启动类加载器
这个类加载器负责放在\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的类库。用户无法直接使用。
扩展类加载器
这个类加载器由sun.misc.Launcher$AppClassLoader实现。它负责
应用程序类加载器
这个类由sun.misc.Launcher$AppClassLoader实现。是ClassLoader中getSystemClassLoader()方法的返回值。它负责用户路径(ClassPath)所指定的类库。用户可以直接使用。如果用户没有自己定义类加载器,默认使用这个
自定义加载器
用户自己定义的类加载器。
双亲委派模型
如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时(即ClassNotFoundException),子加载器才会尝试自己去加载。
优点
- Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存在在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的Bootstrap ClassLoader进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。
- 相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个java.lang.Object的同名类并放在ClassPath中,那系统中将会出现多个不同的Object类,程序将混乱。因此,如果开发者尝试编写一个与rt.jar类库中重名的Java类,可以正常编译,但是永远无法被加载运行。
7. happens-before原则
概述
- 我们无法就所有场景来规定某个线程修改的变量何时对其他线程可见,但是我们可以指定某些规则,这规则就是happens-before,从JDK 5 开始,JMM就使用happens-before的概念来阐述多线程之间的内存可见性。
- 在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
happens-before原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。下面我们就一个简单的例子稍微了解下happens-before ;
1 | i = 1; //线程A执行 |
j 是否等于1呢?假定线程A的操作(i = 1)happens-before线程B的操作(j = i),那么可以确定线程B执行后j = 1 一定成立,如果他们不存在happens-before原则,那么j = 1 不一定成立。这就是happens-before原则的威力。
原则定义
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
规则如下
程序次序规则
一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
锁定规则
一个unLock操作先行发生于后面对同一个锁额lock操作;
volatile变量规则
对一个变量的写操作先行发生于后面对这个变量的读操作;
传递规则
如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
线程启动规则
Thread对象的start()方法先行发生于此线程的每个一个动作;
程中断规则
对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
线程终结规则
线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
对象终结规则
一个对象的初始化完成先行发生于他的finalize()方法的开始;
8. 对象
Java中创建对象的5种方式
使用new关键字 → 调用了构造函数
1 | Employee emp1 = new Employee(); |
使用Class类的newInstance方法→ 调用了构造函数
1 | <!--使用Class类的newInstance方法创建对象。这个newInstance方法调用无参的构造函数创建对象。--> |
使用Constructor类的newInstance方法 → 调用了构造函数
1 | <!--和Class类的newInstance方法很像, java.lang.reflect.Constructor类里也有一个newInstance方法可以创建对象--> |
使用clone方法→ 没有调用构造函数
1 | <!--无论何时我们调用一个对象的clone方法,jvm就会创建一个新的对象,将前面对象的内容全部拷贝进去。用clone方法创建对象并不会调用任何构造函数。--> |
使用反序列化→ 没有调用构造函数
1 | ObjectInputStream in = new ObjectInputStream(new FileInputStream("data.obj")); |
Java 对象生命周期
对象的整个生命周期大致可以分为7个阶段:
创建阶段(Creation)
1 | 在创建阶段系统通过下面的几个步骤来完成对象的创建过程 |
应用阶段(In Use)
1 | 对象至少被一个强引用持有着 |
不可视阶段(Invisible)
1 | 当一个对象处于不可见阶段时,说明程序本身不再持有该对象的任何强引用,虽然该这些引用仍然是存在着的。 |
不可到达阶段(Unreachable)
1 | 对象处于不可达阶段是指该对象不再被任何强引用所持有 |
可收集阶段(Collected)
1 | 当垃圾回收器发现该对象已经处于“不可达阶段”并且垃圾回收器已经对该对象的内存空间重新分配做好准备时,则对象进入了“收集阶段”。 |
终结阶段(Finalized)
1 | 当对象执行完finalize()方法后仍然处于不可达状态时,则该对象进入终结阶段。在该阶段是等待垃圾回收器对该对象空间进行回收。 |
对象空间重新分配阶段
1 | 垃圾回收器对该对象的所占用的内存空间进行回收或者再分配了,则该对象彻底消失了,称之为“对象空间重新分配阶段”。 |
对象内存分配
类加载检查通过后,虚拟机将为新生对象分配内存,对象所需内存大小在类加载完成后可以完全确定,对象内存分配任务就是把一块确定大小的内存从堆中划分出来。
指针碰撞法
- 如果堆中内存是绝对规整的。用过的内存放一边,空闲的放一边,中间放着一个指针作为分界点的指示器,那所分配内存就是把指针向空闲一边移动一段与对象大小相等的距离,即为“指针碰撞”
空闲列表法
- 如果堆中内存不规整,已使用内存和未使用内存相互交错,虚拟机就必须一个列表,记录哪些内存块可用,在分配时从列表中找到一块足够大空间划分给对象,并更新列表上记录,即为“空闲列表”
总结
- 选择何种分配方式,由堆是否规整决定,而堆是否规整由采用的垃圾收集器是否有压缩整理功能决定。
- 使用Serial,ParNew等带Compactg过程的收集器时,系统采用指针碰撞法
- 使用CMS这种基于Mark-Sweep算法的收集器时,系统采用空闲列表法
对象的访问定位
- Java程序需要通过栈上的references数据来操作堆上的具体对象。因为referencesz只是指向对象的一个引用,并没有定义这个引用通过何种方式去方位堆中对象的具体位置。所以对象访问方式取决于虚拟机实现而定的。
- 目前主流的访问方式有使用句柄和直接指针两种。
句柄定位
使用句柄访问时,Java堆中会划分出一块内存来作为句柄池,references中存储的就是对象的句柄地址。句柄中包含对象实列数据与类型数据各组的具体地址信息 references->句柄池->java堆
直接指针定位
如果是直接指针访问,Java堆的布局就必须考虑如何放置访问类型数据相关。
各自优点
- 句柄访问最大好处就是references中存储的是稳定的句柄地址,在对象移动(垃圾收集时移动对象是普遍行为)时只会改变句柄中的实列数据指针,references本身不需要修改。
- 直接指针访问的最大好处是速度快,节省了一次定位的实时间开销。
9. 常量池总结
全局字符串池
string pool也有叫做string literal pool
- 全局字符串池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到string pool中(记住:string pool中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的。)。
- 在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个哈希表,里面存的是驻留字符串(也就是我们常说的用双引号括起来的)的引用(而不是驻留字符串实例本身),也就是说在堆中的某些字符串实例被这个StringTable引用之后就等同被赋予了”驻留字符串”的身份。这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。
class文件常量池
class constant pool
- 我们都知道,class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。
- 字面量就是我们所说的常量概念,如文本字符串、被声明为final的常量值等。
- 符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可(它与直接引用区分一下,直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄)。一般包括下面三类常量。类和接口的全限定名,字段的名称和描述符,方法的名称和描述符。
常量池的每一项常量都是一个表,一共有如下表所示的11种各不相同的表结构数据,这每个表开始的第一位都是一个字节的标志位(取值1-12),代表当前这个常量属于哪种常量类型。
运行时常量池(runtime constant pool)
当java文件被编译成class文件之后,也就是会生成我上面所说的class常量池,那么运行时常量池又是什么时候产生的呢?
- jvm在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在上面我也说了,class常量池中存的是字面量和符号引用,也就是说他们存的并不是对象的实例,而是对象的符号引用值。而经过解析(resolve)之后,也就是把符号引用替换为直接引用,解析的过程会去查询全局字符串池,也就是我们上面所说的StringTable,以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。
举个实例来说明一下:
1 | public class HelloWorld { |
- 回到上面的那个程序,现在就很容易解释整个程序的内存分配过程了,首先,在堆中会有一个”abc”实例,全局StringTable中存放着”abc”的一个引用值
- 然后在运行第二句的时候会生成两个实例,一个是”def”的实例对象,并且StringTable中存储一个”def”的引用值,还有一个是new出来的一个”def”的实例对象 与上面那个是不同的实例
- 当在解析str3的时候查找StringTable,里面有”abc”的全局驻留字符串引用,所以str3的引用地址与之前的那个已存在的相同
- str4是在运行的时候调用intern()函数,返回StringTable中”def”的引用值,如果没有就将str2的引用值添加进去,在这里,StringTable中已经有了”def”的引用值了,所以返回上面在new str2的时候添加到StringTable中的 “def”引用值
- 上面程序的首先经过编译之后,在该类的class常量池中存放一些符号引用,然后类加载之后,将class常量池中存放的符号引用转存到运行时常量池中,然后经过验证,准备阶段之后,在堆中生成驻留字符串的实例对象(也就是上例中str1所指向的”abc”实例对象),然后将这个对象的引用存到全局String Pool中,也就是StringTable中,最后在解析阶段,要把运行时常量池中的符号引用替换成直接引用,那么就直接查询StringTable,保证StringTable里的引用值与运行时常量池中的引用值一致,大概整个过程就是这样了。
总结
- 1.全局常量池在每个VM中只有一份,存放的是字符串常量的引用值。
- 2.class常量池是在编译的时候每个class都有的,在编译阶段,存放的是常量的符号引用。
- 3.运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转存到运行时常量池中,也就是说,每个class都有一个运行时常量池,类在解析之后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致。
class文件常量池和运行时常量池的关系以及区别
class文件常量池存储的是当class文件被java虚拟机加载进来后存放在方法区的一些字面量和符号引用,字面量包括字符串,基本类型的常量。
运行时常量池是当class文件被加载完成后,java虚拟机会将class文件常量池里的内容转移到运行时常量池里,在class文件常量池的符号引用有一部分是会被转变为直接引用的,比如说类的静态方法或私有方法,实例构造方法,父类方法,这是因为这些方法不能被重写其他版本,所以能在加载的时候就可以将符号引用转变为直接引用,而其他的一些方法是在这个方法被第一次调用的时候才会将符号引用转变为直接引用的。
总结:
方法区里存储着class文件的信息和运行时常量池,class文件的信息包括类信息和class文件常量池。
运行时常量池里的内容除了是class文件常量池里的内容外,还将class文件常量池里的符号引用转变为直接引用,而且运行时常量池里的内容是能动态添加的。例如调用String的intern方法就能将string的值添加到String常量池中,这里String常量池是包含在运行时常量池里的,但在jdk1.8后,将String常量池放到了堆中。
10. 类文件结构
1.class类文件结构
- class 文件结构是一组以8位字节为基础单位的二进制流。存储的内容几乎全部是程序运行的必要数据,无空隙。
- 如果需要占用8位字节以上空间的数据,则按照高位在前的方式分割成若干个8位字节进行存储。
- class文件结构采用一种类似C语言体系的伪结构体系,这种伪结构只有无符号数和表两种数据类型。
魔数与Class文件的版本
- class文件的头4个字节称为魔数,唯一作用是确定这个文件是否为一个能被虚拟机接受的文件。
- 魔数值可以自由选择,只要未被广泛使用同事不会引起混淆。
- 紧接着魔数的4个字节是class文件版本号,第5和第6个字节是次版本你好,7和8个字节是class文件版本号(java版本号从45开始。jdk7是51.0)
常量池
- 主次版本号之后的是常量池,常量池可以理解为class文件中的资源仓库。
- class文件结构中只有常量的容量技术是从1开始
- 常量池主要存放两大类常量:字面量(如文本字符串,finald常量)和符号引用(类和接口的全限定名,字段的名称和描述符,方法的名称和描述符)。
- 虚拟机运行时,需从常量池获取对应的符号引用,再在类创建时或运行将诶系会,翻译到哪具体的内存地址中。
访问标志
常量池之后的两个字节代表访问标志,用于识别class是类还是接口,是否为public类型或abstract类型等等。
类索引,父类缩影与接口索引集合
- 这三项按顺序排列在访问标志之后,class文件中由这三项来确定整个类的继承关系。
- 类索引用于确定类的全限定名,父类索引用于确定类的父类权限定名。接口索引集合描述类实现了哪些接口
字段表集合
用于描述接口或类中声明的变量。字段包裹类级别的变量和实列变量。不包括方法内部声明的局部变量。
方法表集合
方法表结构依次包括访问标志,名称索引,描述索引,属性集合.
11. 虚拟机字节码执行引擎
执行引擎是Java虚拟机最核心的组成部分之一。
运行时栈帧结构
- 栈帧用于虚拟机进行方法调用和方法执行的数据结构。
- 栈帧存储了方法的局部变量表,操作数据栈,动态链接和返回地址等信息。每一个方法从调用开始至执行完成过程,都是在虚拟机中入栈到出栈的过程。
- 栈帧需要分配多少内存,不受程序运行时期变量数据影响,取决虚拟机的具体实现。
1.局部变量表
- 一组变量值存储空间,存放方法参数和方法内部的局部变量, 类编译为class文件时就在方法的code属性max_locals中确定了方法局部变量表的最大容量。
- 一变量槽”Slot“为最小单位,虚拟机没指明solt的占用内存大小,一般每个solt都可以存放一个boolean,bye,char,short,int,float,reference或returnAddress类的数据(32位或更小物理内存存放)。
- 它是建立在线程的堆栈上。是线程私有的数据,所以是线程安全的。
- 虚拟机通过索引的方式使用局部变量表。执行方法时,通过局部变量表完成参数值到参数变量列表的过程。如果执行实列方法(非static),变量表中第0位索引的slot默认用户传递方法的引用。
2.操作数栈
- 它是一个后入先出的栈。同局部变量表一样,最大深度在编译时写入到code属性的max_stacks中。
- 操作数栈的每一个元素可以是任意的Java数据类型。32位的数据类所占的栈容量为1,64位栈容量2(long,double)
- 一个方方法刚开始执行时,操作数栈时空的。在方法执行过程中,通过各种字节码指令往操作数栈写入和提取内容,也就是出栈/入栈操作.
3.动态链接
- 每个栈帧都包含一个指向运行时常量池中该栈所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接
4.方法返回地址
- 一个方法执行后,只有通过正常完成出口和异常完成出口两种方式退出。
- 正常完成出口:当执行引擎遇到一个方法返回的字节码指令
- 异常完成出口:方法执行过程中遇到异常且方法中未处理此异常,就会导致方法退出。
- 方法正常退出时,调用者的程序计数器的值可以作为返回地址。
- 方法退出的过程等于就是把当前栈帧出栈。
方法调用
- 方法调用不等于方法执行,方法调用阶段唯一任务就是确定被调用方法的版本。
- 一切方法调用在Class文件里存储都只是符号引用,而不是方法在实际运行时内存布中的入口地址(直接引用)。
1.解析
- 所有方法调用重点目标方法在Class文件里都是一个常量池的符号引用,解析阶段会将一部分符号引用转化为直接引用。
- “编译器可知,运行期间不可变”这类方法的调用称为解析.(静态方法和私有方法)
- 只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本.(比如静态方法,私有方法,实列构造器,父类方法)它们在类加载时候会把符号引用解析为该方法的直接引用。
2.静态分派
在重载时时通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译可知的。
静态分派的典型应用就是方法重载
1 | //左边是静态类型 右边是实际类型 |
3.动态分派
方法的重写就是动态分派的体现。
12. 高效并发
主内存与工作内存
- JAVA内模型规定所有的变量都存储在主内存中,每个线程都有自己的工作内存,线程的工作内存中保存的是当前线程使用到的变量值的副本(主内村拷贝过来的)。
- 线程对变量的所有操作都必须在工作内存中进行,不能直接与主内存进行读写交.线程间相互的传值需要通过主内存完成。
内存间的交互
JAVA内存模型定义了以下8种操作来完成内存交互工作:
lock
(锁定):作用于主内存的变量。把一个变量标识为一条线程独占的状态。unlock
(解锁):作用于主内存的变量.把一个处于锁定状态的变量释放出来。read
(读取):作用于主内存的变量。把一个变量值从主内存传输到线程的工作内存中,以便随后的load
动作使用。load
(载入):作用于工作内存的变量,它把read操作从主内存中得到的值放入工作内存的变量副本中。use
(使用):作用与工作内存的变量.它把工作内存中一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时就会执行这个操作。assign
(赋值):作用于工作内存的变量,它把一个从执行引擎收到的赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。store
(存储):作用于工作内存的变量,它把工作内存中的一个变量的值传送到主内存中,以便随后的wirte操作使用。wirte
(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量值放入主内存中。上述操作虚拟机实现时保证每一种操作都是原子性的。且比如满足如下规则
不 _允许一个变量从主内存读取但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现
变量在工作内存中改变之后必须把该变化同步回主内存
一个新的变量必须在主内存中诞生。不允许工作内存直接使用未初始化的变量。
一个变量同一个时刻只能一条线程进行lock操作,但是lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
如果一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作。
如果一个变量事先没有被lock操作锁定,那将不允许执行unlock操作,也不允许去unlock一个被其它线程锁定住的变量
对一个变量执行unlock操作之前,必须先把此变量同步回主内存中
对于volatile型变量的特殊规则
- volatile是虚拟机提供的最轻量级同步机制。它具备两种特性:保证被修饰的变量对所有线程可见(即可见性)和禁止指令重排序。
- volatile只能保证可见性,不能保证操作运算的原子性。
- 运算结果并不依赖变量的当前值时和不需要与其他的状态变量共同参与不变约束时适合使用volatile
对于long和double型变量的特殊规则
- 对于64位的数据类型long和double,在内存模型中有一条相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据类型分为两次32位的操作来新型。
- 允许虚拟机实现选择可以不保证64位数据类型的
load
,storm
,read
,write
这个四个操作的原子性(ong和double的非原子性协定) - JAVA内存模型虽然允许虚拟机不把
long
和double
变量的读写实现成原子操作,但允许虚拟机选择把这些操作实现为具有原子性的操作。目前各种平台下的虚拟机几乎都选择吧64位数据类型读写操作作为原子操作对待。