书籍介绍:https://book.douban.com/subject/34907497/
内存区域
运行时数据区域
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域
这些区域有各自的用途,以及创建和销毁的时间
- 有的区域随着虚拟机的进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁
根据《Java虚拟机规范》的规定,
Java
虚拟机所管理的内存将会包括以下几个运行时数据区域:
程序计数器
程序计数器(
Program Counter Register
)是一块较小的内存空间
- 他可以看作是当前线程所执行的字节码的行号指示器
在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的
- 在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条指令中的指令
因此,为了线程切换后能恢复到正确的位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储
我们称这类内存区域为线程私有的内存
如果程序正在执行一个
Java
方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)
此内存区域是唯一一个没有规定任何
OutOfMemoryError
情况的区域
Java虚拟机栈
Java虚拟机栈也是线程私有的,它的生命周期与线程相同
虚拟机栈描述的是Java方法执行线程内存模型:
每个方法被执行的时候,Java虚拟机都会同步创建一个战争用于存储局部变量表、操作数栈、动态链接、方法出口等信息
每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程
局部变量表存放了编译期可知的各种Java虚拟机:
- 基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用和returnAddress类型(指向了一条字节码指令的地址)
这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出
StackOverflowError
异常如果Java虚拟机容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常
本地方法栈
本地方法栈与虚拟机栈所发挥的作用很相似
- 区别在于虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地方法服务
Java堆
几乎所有的对象实例以及数组都应当在堆上分配
垃圾收集器管理的主要区域
从分配内存的角度,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Theard Local Allocation Buffer, TLAB),以提升效率
Java堆可以实现成固定大小的,也可以是可扩展的,主流的Java虚拟机都是可扩展的(通过参数最大值:
-Xmx
和最小值:-Xms设定)
- Java堆没有内存分配实例对象,并且无法扩展时,将会抛出OutOfMemoryError异常
方法区
和Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、及时编译器编译后的代码缓存等数据
- 类信息:即 Class 类,如类名、访问修饰符、常量池、字段描述、方法描述等
关于永久代(
Permanent Generation
)这个概念:
- 由于HotSpot虚拟机使用永久代来实现方法区,故许多Java程序员都习惯把方法区称呼为永久代
- 但这种设计更容易导致内存溢出问题
在JDK6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区,到了JDK7
已经把原本放在永久代的字符串常量池、静态变量等移除,而到了
JDK8
,终于完全废弃了永久代的概念
- 改用了与JPockit、J9一样在本地内存中实现的元空间中
垃圾收集行为比较少,甚至可以不实现垃圾收集
- 这区域的内存回收目标主要是针对常量池的回收和对类型的卸载
无法满足新的内存分配需求时,将抛出OutOfMemoryError异常
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分
Class文件中除了有类的版本、字段、方法、接口等描述信息外
- 还有常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,在类加载后存放到方法区
直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,但被频繁地使用,而且也可能导致OutOfMemoryError异常
在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式
- 它可以使用Native函数库直接分配堆外内存
- 然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作
这样能在一些场景显著提高性能,因为避免了在Java堆和Native堆中来回复制数据
一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存
使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现
OutOfMemoryError
异常
堆对象
对象的创建
遇到一条New指令,虚拟机的步骤:
检查这个指令的参数能否在常量池中定位到一个类的符号引用
- 并检查这个符号引用代表的类是否已被加载、解析和初始化过
如果没有,必须先把这个类加载进内存
类加载检查通过后,虚拟机将为新对象分配内存,类加载完就可以确定存储这个对象所需的内存大小
将分配到的内存空间初始化为零值
设置对象头(Object Header)中的数据:
- 包括这个对象是哪个类的实例、如何才能找到类的元数据、对象的哈希码(实际在调用
Object::hashCode()
方法才计算)、对象的GC分代年龄等此时从虚拟机的角度看,对象已经产生,但从
Java
程序的角度看,构造函数还没有执行执行完初始化函数,一个真正的对象才算完全构造出来
在第二步中,为对象分配内存,就是在内存划分一块确定大小的空闲内存,但存在两个问题:
如何划分空闲内存和已被使用的内存?
假设Java堆中内存是绝对规整的,空闲内存和被使用内存被分到两边,中间放置指针作为分界点的指示器
- 那分配内存就是把指针向空闲内存一定一段,这种方式成为 指针碰撞(
Bump The Pointer
)但如果Java堆内存不是规整的,那就没有办法简单地进行指针碰撞了,虚拟机需要维护一个列表
- 记录哪些内存块可以使用,在分配内存的时候,找到一块足够打的内存划分给对象实例
- 并更新列表上的记录,这种方式被称为 空闲列表(Free List)
事实上,这由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定
因此,当使用Seria、parNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效
而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上只能采用较为复杂的空闲列表来分配内存
如何处理多线程下,内存分配问题?
- 对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性
- 把内存分配的动作按照线程划分在不同的空间之中进行
- 即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(
Theard Local Allocation Buffer, TLAB
)- 哪个线程要分配内存就先在本地线程分配缓冲中分配,只有缓冲使用完了,分配新的缓存区时需要同步锁定
- 通过
-XX:+/-UseTLAB
参数设置是否使用TLAB
对象的内存布局
对象头(Header):
第一部分:存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
- (官方称之为
Mark Word
)第二部分:类型指针,即对象指向它的类型元数据的指针,虚拟机通过这个指针来判断这个对象是哪个类的实例
- 如果是数组对象,将会在对象头存储数组长度,以确定对象大小
实例数据(Instance Data):
在程序代码里面所有定义的各种类型的字段内容都必须记录。
这部分的存储顺序受到虚拟机分配策略参数(
-XX:FieldsAllocationStyle
参数)和Java源码中定义顺序的影响HotSpot虚拟机默认的分配顺序为
longs/doubles、ints、shorts/chars、bytes/booleans、oops (Ordinary Object Pointers, OOPs)
- 相同宽度的字段总是被分配到一起存放,满足这个条件的前提下,父类定义的变量会出现在子类之前
对齐填充(Padding):
- HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍
- 因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全
对象的访问定位
Java程序通过栈上的reference数据来操作堆上具体对象,主流的访问方式主要有以下两种:
- 使用句柄:Java堆可能会划分一块内存作为句柄池,reference中存储的就是对象的句柄地址
- 而句柄包含了对象实例数据与类型数据各自Juin的地址信息
- 优势:在对象被移动(垃圾收集时移动对象是非常普遍的行为)时
- 只会改变句柄中的实例数据指针,而不需修改reference
使用直接指针:Java堆中对象的内存布局必须考虑如何放置访问类型数据的相关信息,
reference
中存储的直接就是对象地址
- 如果只是访问对象本身,就不需要多一次间接访问的开销
优势:速度快,节省一次指针定位的时间开销
内存溢出异常
Java堆溢出
Java堆内存的OutOfMemoryError异常是实际应用最常见的内存溢出情况
- 异常堆栈信息
java.lang.OutOfMemoryError
会进一步提示Java heap space
解决方法:
- 首先通过内存映像分析工具(如
Eclipse Memory Analyzer
)对Dump出来的堆转储快照进行分析- 分析清楚是出现内存泄漏(Memory Leak)还是内存溢出(Memory overflow),即导致OOM的对象是否是必要的
- 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链
- 找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们
- 根据泄漏对象的类型信息以及它到GC Roots 引用链的信息,一般可以比较准确地定位到这些对象创建的位置
- 进而找到产生内存泄漏的代码具体位置
- 如果是内存溢出,检查Java虚拟机的堆参数(-Xmx与-Xms)设置
- 检查代码对象生命周期是否过长、存储结构设计不合理等情况,减少程序运行的内存消耗
虚拟机栈和本地方法栈溢出
线程请求的栈深度大于虚拟机所允许的最大深度,将抛出
StackOverflowError
异常如果虚拟机栈允许动态扩展,当扩展栈容量无法申请到足够的内存,将抛出OutOfMemoryError异常
方法区和运行时常量池溢出
运行时常量池溢出时,在
OutOfMemoryError
异常后面跟随的提示信息是PermGen space类加载过多,会导致方法区内存溢出,在实际的应用中,如Spring框架,会使用到CGLib这类字节码技术,会产生大量的动态类
- 容易导致内存溢出,需要注意垃圾回收;除此之外常见的还有大量JSP或动态JSP文件应用、基于OSGi的应用等
在JDK8后,永久代完全退出,元空间作为替代者登场
在默认设置下,动态创建新类型已经很难使虚拟机出现产生方法区内存溢出
本机直接内存溢出
--XX:MaxDirectMemorySize
设置,如不指定,默认与Java堆最大值(-Xmx指定)一致出现的明显特征,Heap Dump文件不会看见明显的异常情况,如发现内存溢出后产生的Dump文件很小
- 而程序又直接或间接使用了
DirectMemory
(典型的间接使用是NIO),那可以考虑重点检查直接内存的问题
内存分配策略
对象已死?
引用计数算法:
- Java虚拟机并不是通过引用计数算法来判断对象是否存活的
- 举个简单的例子:
- 对象objA和objB都有字段instance,赋值令
objA.instance = objB 及 objB.instance = objA
- 除此以外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问
- 但是他们因为互相引用着对方,导致他们的引用计数都不为领,引用计数算法也就无法回收它们
可达性分析算法:
- 可达性分析算法的基本思路就是通过一系列成为GC Roots的根对象作为起始节点集
- 从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链(
Reference Chain
)- 如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时
- 则证明此对象是不可能再被使用的
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
在虚拟机栈(栈帧中的本地变量表)中引用的对象
- 譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量
在方法区中常量引用的对象,譬如字符串常量(String Table)里的引用
在本地方法栈JNI(即通常所说的Native方法)引用的对象
Java虚拟机内部的引用,如基本数据类型对应的Class对象
- 一些常驻的异常对象(比如NullPointerException、OutOfMemoryException)等,还有系统类加载器
所有被同步锁(sysnchronized关键字)持有的对象
反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象临时性地加入
再谈引用
在JDK1.2版之后,Java对引用的概念进行了扩充
将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种
这4种引用强度依次逐渐减弱:
- 强引用是最传统的引用的定义,是指在程序代码之中普遍存在的引用赋值
- 即类似于
Object obj = new Objec()
这种引用关系
- 永远不会被垃圾收集器回收
- 软引用是用来描述一些还有用、但非必须的对象
- 弱引用也是用来描述那些非必须对象,但是他的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止
- 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系
- 一个对象是否有虚引用的存在,完全不会对其生存时间造成影响,也无法通过虚引用来取得一个对象实例
- 为一个对象设置虚引用关联的唯一目的只是为了能再这个对象被收集器回收时收到一个系统通知
生存还是死亡?
在可达性算法中被判定为不可达对象后,至少要经历两次标记过程对象才会真正死亡:
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它会被第一次标记
- 随后进行一次筛选,筛选的条件是此对象是否有必要执行
finalize()
方法加入对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过
- 那么虚拟机将这两种情况都视为没有必要执行
如果这个对象判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中
- 并在稍后由一条虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的
finalize()
方法finalize()方法是对象逃脱死亡命运的最后一次机会
- 如果对象在finalize()中重新与引用链上的一个对象建立关联即可避免被回收
- 譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量。
回收方法区
方法区的垃圾收集主要回收两个部分内容:废弃的常量和不再使用的类型:
回收废弃常量:一个字符串java曾经进入常量池,但是当前系统有没有任何一个字符串对象的值是java
- 换句话说,已经没有任何字符串对象引用常量池中的
java
常量,且虚拟机中也没有其他地方引用这个字面量如果再这个时候发生内存回收就会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似
判断一个类型是否属于不再被使用的类需要满足下面三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
- 加载该类的类加载器已经被回收
- 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
垃圾收集算法
分代收集理论
当前商业虚拟机的垃圾收集器,大多数都遵循了分代收集(Generational Collection)的理论进行设计,它建立在两个分代假说之上:
弱分代假说(Weak Generational Hypothesis):
- 绝大多数对象都是朝生夕灭的
强分代假说(Strong Generational Hypothesis):
- 熬过越多次垃圾收集过程的对象越难以消亡
这两个分代假说共同奠定了多款常用的垃圾收集器的一致设计原则:
- 收集器应该将Java堆划分出不同的区域,
- 然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储
- 显而易见,如果剩下的都是难以消亡的对象,那就把它们集中放在一起
- 虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾回收的时间开销和内存空间的有效利用
- 在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了
Minor GC
Major GC
,Full GC
这样的回收类型的划分
- 也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而展示出了:
- 标记-复制算法,标记-清除算法,标记-整理算法等针对性的垃圾收集算法
跨代引用假说(
Intergenerational Reference Hypothesis
):跨代引用相对于同代来说仅占极少数
- 这其实是可根据前两条假说逻辑推理得出的隐含推论:
- 存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的
- 例如,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡
- 该引用会使得新生代对象在收集时同样得以存活,进而在年龄增大之后晋升到老年代中,这时跨时代引用也随即被消除了
对于不同分代的名词定义:
- 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
- 新生代收集(Minor GC/Yong GC):
- 指目标只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC):
- 指目标只是老年代的垃圾收集
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集
- 目前只有GI收集器会有这种行为
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
标记-清除算法
标记-清除算法是最基础的垃圾收集算法,该算法分为标记和清除两个阶段:
- 首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象
- 也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程
该算法有两个缺点:
- 执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要回收的,这时必须进行大量标记和清除的动作
- 导致标记和清除两个过程的执行效率都随对象数量增长而降低
- 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片
- 空间碎片太多可能会导致当以后再程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集算法
标记-复制算法
标记-复制算法常被称为复制算法,为了解决标记-清除算法面对大量可回收对象时执行效率低的问题
- 该算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块
当这一块用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉
标记-整理算法
标记-整理算法标记的过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理
- 而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存
标记-清理算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的
垃圾收集器
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器
CMS收集器是基于标记-清除算法实现的,它的运作过程分为四个步骤,包括:
初始标记(CMS initial mark)
并发标记(CMS concurrent mark)
重新标记(CMS remark)
并发清除(CMSconcurrent sweep)
其中初始标记、重新标记这两个步骤仍然需要
Stop The World
初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快
并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程
- 这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
- 这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短
最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象
- 所以这个阶段也是可以与用户线程同时并发的
CMS收集器至少有以下三个明显的缺点:
- 首先,CMS收集器对处理器资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿
- 但却会因为占用了一部分线程(或者说处理器的计算器的计算能力)而导致应用程序变慢,降低总吞吐量
- CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说
- 当处理器核心数量不足4个时,CMS对用户程序的影响就可能变得很大。
- 如果应用程序本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低
- 为了缓和这种情况,虚拟机提供了一种称为增量式并发收集器的CMS收集器变种
- 在并发标记、清理的时候让收集器线程、 用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间
- 然后,由于CMS收集器无法处理浮动垃圾(
Floating Grabage
)
- 有可能出现
Concurrent Mode Failure
失败进而导致另一次完全Stop The World
的Full GC的产生- 在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随着有新的垃圾对象不断产生
- 但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉
- 这一部分垃圾就称为浮动垃圾
- 最后,CMS是一款基于标记-清除算法实现的收集器,这意味着收集结束时会有大量空间碎片产生
- 空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间
- 但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况
Grabage First收集器
G1是一款主要面向服务端应用的垃圾收集器。
G1不再坚持固定大小以及固定数量的分代区域划分
- 而是把连续的Java堆划分为多个大小相等的独立区域(Region)
- 每一个Region都可以根据需要,扮演新生代的Eden空间、
Survivor
空间或者老年代空间Region中还有一类特殊的Humongous区域,专门用来存储大对象
G1认为只要大小超过一个Region容量一半的对象即可判定为大对象
- 每个Region的大小可以通过参数
-XX:G1HeapRegionSize
设定
- 取值范围为
1MB~32MB
,且应为2的N次幂而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的
Humongous Region
之中
- G1的大多数行为都把Humongous Region作为老年代的一部分进行看待
虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合
这种使用Region划分内存空间,以及具有优先级的区域回收方式
- 保证了G1收集器在有限的时间内获取尽可能的收集效率。
如果不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作)
G1收集器的运作过程大致可划分为以下四个步骤:
初始标记(Initial Marking):
- 仅仅只是标记一下GC Roots能直接关联到的对象,并且修改了
TAMS(Top at Mark Start)
指针的值
- 让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象
- 这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的
- 所以G1收集器在这个阶段实际并没有额外的停顿
并发标记(Concurrent Marking):
- 从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行
- 当对象图扫描完成以后,还要重新处理STAB记录下来的在并发时有引用变动的对象
最终标记(Final Marking):
- 对用户线程做另一个短暂暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录
筛选回收(Live Data Counting and Evacuation):
- 负责更新Region的统计数据,对各个Region的回收价值和成本进行统计数据,对各个Region的回收价值和成本进行排序
- 根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中
- 再清理掉整个旧Region的全部空间
- 这里的操作涉及存活的对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的
与CMS的标记-清除算法不同,G1从整体来看是基于标记-整理算法实现的收集器,但从局部(两个
Region
之间)上看是基于标记-复制算法实现
- 无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存
这种特性有利于程序长时间运行
- 在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集
ZGC收集器
首先从ZGC的内存布局说起,与
Shenandoah
和G1一样ZGC也采用基于Region的堆内存布局,但与它们不同的是
- ZGC的Region具有动态性——动态创建和销毁,以及动态的区域容量大小
类加载机制
类加载机制的过程是什么呢?
JVM把描述类的数据从Class文件中 加载到内存中,并对其进行 校验, 解析, 初始化
- 最终就会形成能被JVM直接使用的Java类型,这个过程就是虚拟机的 类加载机制
这样的类加载机制有什么优缺点呢?
与其他语言在编译期间就需要连接不同,在Java中 类型的加载、连接和初始化都是在程序运行期间完成的
虽然这样为Java应用 提高了极大的扩展性和灵活性,但是 提前编译(
AOT
)技术就有了困难(因为需要是在运行前编译)
- 以及在 类加载时会稍微增加一些性能开销
Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的
类加载的时机
- 一个类的生命周期:加载到内存开始,移出内存结束
- 类的整个生命周期会经历七个阶段:
- 加载(loading),验证(verification),准备、解析(Resolution)、初始化、使用、卸载
- 我们通过一个图来理解:
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的
- 而解析阶段则不一定(因为有时可以在初始化阶段之后再进行)
【注】:这五个阶段的顺序是确定的,指的是开始顺序是确定的
- 而执行过程其实是相互交叉混合进行的,
验证,准备,解析
==连接过程什么时候进入第一个阶段加载并没有什么强制约定,这可以由JVM自由把控
但是对于初始化阶段,《Java虚拟机规范》则是严格规定 有且只有六种情况可以对类进行初始化,俗称主动引用
- 初始化之前,加载、验证、准备阶段已经完成
遇到 new、 getstatic、 putstatic或 invokestatic这四条字节码指令时
- 如果该类没有进行过初始化,则需要先对其进行初始化
相对应于Java代码场景就是:
使用 new关键字创建实例化对象的时候
读取或设置一个类的 静态字段(如果该字段被final关键字修饰,则会在编译器iu放入到了常量池中,不算)
调用一个类的 静态方法
通过用
java.lang.reflect
包对类进行 反射调用的时候初始化该类时,如果有父类且没有初始化,则 先触发父类的初始化
VM启动时,会首先对 主类(包含main()方法的类)进行初始化
JDK7以后,通过 动态语言支持,如果一个
java.lang.invoke.MethodHandle
实例最后的解析结果为:
- REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类的 方法句柄
- 且对应的类没有初始化,则需要先触发其初始化
被 default关键字修饰的接口,如果该接口的实现类发生了初始化,那在这之前要先初始化该接口
除了上面6种,其他引用类的方式都不会触发初始化,俗称被动引用
我们分析几种被动引用的情况
通过子类引用父类的静态字段,不会导致子类初始化
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
public class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}
- 结果分析:对于静态字段,只有直接定义这个字段的类才会被初始化
- 因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化
- 至于是否要触发子类的加载和验证阶段,在《Java虚拟机规范》中并未明确规定,所以这点取决于虚拟机的具体实现
通过数组定义来引用类,不会触发此类的初始化
package org.fenixsoft.classloading;
public class NotInitialization {
public static void main(String[] args) {
SuperClass[] sca = new SuperClass[10]; //SuperClass 在上面代码中提到
}
}
结果分析:
这段代码没有输出”SuperClass init!”,说明并没有触发类
org.fenixsoft.classloading.SuperClass
的初始化阶段其实它初始化了名为
[Lorg.fenixsoft.classloading.SuperClass
的类的初始化阶段它对于用户代码来说并不是合法的类名,因为它是由虚拟机自动生成的,直接继承于
java.lang.Object
的子类,创建动作由字节码指令newarray触发这个类代表了一个元素类型为
org.fenixsoft.classloading.SuperClass
的一维数组数组中应有的属性和方法(用户可直接使用的只有被修饰为public的length属性和clone()方法)都实现在这个类里
正是这个类包装了数组元素的访问,所以当检查到发生数组越界时,会抛
java.lang.ArrayIndexOutOfBoundsException
异常【注】:越界检查不是封装在数组元素访问的类中,而是封装在数组访问的xaload、xastore字节码指令中
常量(final修饰)在编译阶段会存入调用类的常量池中
- 本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
package org.fenixsoft.classloading;
public class ConstClass {
static {
System.out.println("ConstClass init!");
}
public static final String HELLOWORLD = "hello world";
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(ConstClass.HELLOWORLD);
}
}
结果分析:
在编译阶段通过常量传播优化,已经将此常量的值“hello world”直接存储在NotInitialization类的常量池中
- 以后NotInitialization对常量ConstClass.HELLOWORLD的引用,实际都被转化为NotInitialization类对自身常量池的引用了
说白了,NotInitialization的Class文件之中并没有ConstClass类的符号引用入口,当它们编译成Class文件之后就不存在什么联系了
接口的加载过程与类加载过程稍有不同,针对接口需要做一些特殊说明:接口也有初始化过程,这点与类是一致的
上面的代码都是用静态语句块
static{}
来输出初始化信息的,而接口中不能使用static{}
语句块
- 但编译器仍然会为接口生成
< clinit >()
类构造器,用于初始化接口中所定义的成员变量接口与类真正有所区别的是前面讲述的六种“有且仅有”需要触发初始化场景中的第三种:
- 当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化
- 只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化
类加载的过程
类加载分为5个阶段:加载、验证、准备、解析和初始化
加载
加载阶段是整个 “类加载” 过程中的一个阶段
在加载阶段我们要完成三项工作:
通过一个类的全限定名获取定义此类的二进制流
将这个字节流所代表的 静态存储结构转化为 方法区的运行时数据结构
在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区的这个类的各种数据的访问入口【注】:《Java虚拟机规范》对这三点要求其实并不是特别具体,留给VM实现与Java应用的灵活度都是相当大的
尤其是第1条,它并没有规定这个二进制流必须从哪里(比如
Class
文件)获取,以及如何获取比如:可以从ZIP压缩包中获取,从网络中获取,可以运行时计算生成(动态代理技术),从加密文件获取等,因此也对生生成了不同的Java技术
相对于类加载过程的其他阶段,非数组类型的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的阶段
因为在加载阶段,我们可以通过JVM内置的引导类加载器完成,也可以通过自定义的类加载器去控制二进制流的获取方式来完成
- 这样我们就可以根据自己的想法来赋予程序获取运行代码的动态性
【注】:对于数组类而言,加载阶段略有不同
- 因为数组类本身不通过类加载器创建,而是由JVM直接在内存中动态构建出来的
但是数组类中的元素类最终还要靠类加载器去完成。 数组类的创建规则如下:
如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型,注意和前面的元素类型区分开来)是引用类型
- 那就递归采用本节中定义的加载过程去加载这个组件类型,数组C将被标识在加载该组件类型的类加载器的类名称空间上
如果数组的组件类型不是引用类型(例如
int[]
数组的组件类型为int),JVM将会把数组C标记为与引导类加载器关联数组类与它的组件类型的的可访问性一致,如果组件类型不是引用类型,那么数组类的可访问性默认为public
获取二进制流后,VM外部的二进制字节流就按照VM所需的格式存储在方法区之中
- 之后在Java堆中实例化一个
java.lang.Class
类的对象,这样便可以通过该对象访问方法区中的这些数据加载阶段与连接阶段的部分动作是交叉进行了,加载阶段尚未完成,连接阶段可能已经开始
验证
验证是连接的第一步
目的:
- 确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,并且运行后不会危害VM自身的安全
意义:
- Java虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃
- 所以验证字节码是Java虚拟机保护自身的一项必要措施
不同的虚拟机对类验证的实现可能会有所不同,但 大致都会完成以下四个阶段的验证:
- 文件格式的验证
- 元数据的验证
- 字节码验证
- 符号引用验证
文件格式的验证:
验证字节流是否符合Class文件格式的规范,并能被当前VM处理
比如:是否以魔数0xCAFEBABE开头
主、次版本号是否在接受范围内、常量池的常量是否支持等;
主要目的是保证输入的字节流能正确地解析并存储于方法区之内(这也说明了不同的阶段是混合交叉运行)
经过该阶段的验证后,这段字节流才会进入内存的方法区中进行存储
- 所以 后面的三个验证都是基于方法区的存储结构进行的
元数据的验证:
对类的元数据信息进行语义校验(其实就是对类中的各数据类型进行语法校验),保证不存在与《Java语言规范》定义相悖的元数据信息
- 比如:这个类是否有父类、如果有父类那该类是否能被继承(final修饰的类不能作父类)
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法等等。
字节码验证:
该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析
- 以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为
符号引用验证:
最后一个阶段的校验行为发生在VM将 符号引用转化为直接引用的时候,也就是在 解析阶段中发生
符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验
- 通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。
如果无法通过符号验证,JVM将会抛出一个
java.lang.IncompatibleClassChangeError
的子类异常验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别
- 只要通过了验证,其后就对程序运行期没有任何影响了
准备
准备阶段是正式为静态变量分配内存并设置初始值的阶段
这个阶段进行内存分配的 仅仅是静态变量,没有实例变量
- 实例变量将会在对象实例化的时候随着对象一起分配在Java堆中
这是所说的初始值,指的是数据类型的零值,而不是被在Java代码中被显式赋予的值
- 初始化Java代码中被显示赋予的值是用putstatic指令,存放于类构造器
< clinit >()
方法之中,也就是在初始化阶段才被执行如果类字段的字段属性表中存在 ConstantValue属性(final和static同时修饰)
- 那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值
- 我们通过被动引用的第三个例子也可以知道,被final和static同时修饰的变量(常量)在编译时期就被放入到了常量池中
解析
解析阶段是JVM将常量池内的符号引用替换为直接引用的过程;(符号引用–>直接引用)
符号引用:
以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可
- (符号引用与虚拟机实现的内存布局 无关)(各种JVM实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的)
直接引用:
可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄
- (直接引用是和虚拟机实现的内存布局 直接相关的)
初始化
类的初始化阶段是类加载过程的最后一个步骤,直到初始化阶段,JVM才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序
在准备阶段,变量已经赋过一次系统要求的初始零值,而在初始化阶段
- 则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源
初始化阶段就是执行类构造器
< clinit >()
方法的过程
- 该方法并不是程序员在Java代码中直接编写的,而是Javac编译器的自动生成物
类加载器
类加载器有什么作用呢?
对于任意的一个类,都必须由加载它的类加载器,这个类本身一起共同确立其在JVM中的唯一性
- 每一个类加载器,都拥有一个独立的类名称空间
换种说话就是:比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义
- 否则,即使这两个类来源于同一个Class文件,被同一个JVM加载
- 只要加载它们的类加载器不同,这两个类也不相等
【注】:这里所指的相等,包括代表类的Class对象的equals()方法、
isAssignableFrom()
方法、isInstance()方法的返回结果
- 也包括了使用instanceof关键字做对象所属关系判定等各种情况
JVM中有几类类加载器呢?
其实从JVM的角度看,大致可以分为两类:
启动类加载器(
Bootstrap ClassLoader
):
- JVM自身的一部分,由C++实现的
- 只针对HotSpot虚拟机,JDK9以后也采用了类似的虚拟机与Java类互相配合来实现Bootstrap ClassLoader的方式
- 所以HotSpot也有一个无法获取实例的代表Bootstrap ClassLoader的Java类存在
其他所有的类加载器:
- 全部由Java实现,独立存在于VM外部,并全都继承自抽象类
java.lang.ClassLoader
【注】:从Java开发人员的角度来看,类加载器分的更细致
- 自JDK1.2以来,Java一直保持着三层类加载器,双亲委派的类加载器结构
类加载器双亲委派模型(Parents Delegation Model)
启动类加载器(
Bootstrap ClassLoader
):也叫引导类加载器,负责加载(存放在
<JAVA_HOME>\lib
目录,或者被Xbootclasspath参数所指定的路径中存放的
- 而且是JVM能够识别(按文件名识别,如
rt.jar、tools.jar
))的类库加载到JVM的内存中启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时
- 如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可
扩展类加载器(Extension Class Loader):
- 在类
sun.misc.Launcher$ExtClassLoader
中以Java代码的形式实现的它负责加载
<JAVA_HOME>\lib\ext
目录中,或者被java.ext.dirs
系统变量所指定的路径中所有的类库通过名字我们可以推断出,JVM开发团队允许用户将自己的类库放置在
<JAVA_HOME>\lib\ext
目录中
- 以扩展Java SE的功能。因为是由Java语言实现的,所以我们可以直接在程序中使用该类加载器加载Class文件
【注】:在JDK9以后其实扩展机制已经可以被模块化代替了,因为模块化有天然的扩展能力
应用程序类加载器(Application Class Loader):
由
sun.misc.Launcher$AppClassLoader
来实现
- 该类加载器是ClassLoader类中的getSystem-ClassLoader()方法的返回值,所以我们也称其为系统类加载器
它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器
【注】:如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器
自定义类加载器(User Class Loader):
JDK 9之前的Java应用都是由这三种类加载器互相配合来完成加载的
当然我们也可以加入自定义的类加载器进扩展,比如:
增加存储于磁盘之外的Class文件来源;
实现类的隔离、重载等功能;
双亲委派模型的要求是什么?
顶层必须是启动类加载器
其余的类加载器都有自己的父类加载器;注意它们之间不是继承关系,而是通常使用组合(Composition)关系来复用父加载器的代码
双亲委派模型”的工作过程:
如果一个类加载器收到了类加载的请求,它首先会把这个请求委派给父类加载器去完成,每一层次的类加载器都是如此
因此所有的加载请求最终都应该传送到最顶层的启动类加载器中
- 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载
双亲委派模型的好处:
Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系
例如类
java.lang.Object
,它存放在rt.jar
之中无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类
反之如果没有该模型,那么不同的类加载器可能会加载同一个名为
java.lang.Object
的类那么 在程序的classPath上就会有不同的Object类,那么我就不能保障Java类型体系了
双亲委派模型的核心代码: (全部集中在
java.lang.ClassLoader的loadClass()
方法之中)
protected synchronized Class<?> loadClass(String name, boolean resolve) throwsClassNotFoundException
{
// 首先,检查请求的类是否已经被加载过了
Class c = findLoadedClass(name);
if (c == null) { //如果未加载过就交给父类加载器
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException,说明父类加载器无法完成加载请求
}
if (c == null) {
// 在父类加载器无法加载时,再调用本身的findClass方法来进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
逻辑解析:
检查请求加载的类是否已经被加载过
如果没有则调用父加载器的loadClass()方法
如果父加载器为空,则默认使用启动类加载器为父加载器
如果父加载器失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法尝试进行加载
破坏双亲委派模型模型(一共三次):
第一次破坏:
因为JDK1.2以后双亲委派模型才被引入
但是在这之前类加载器的概念以及
java.lang.ClassLoader
就已经存在
- 所以面对当时已经存在的自定义类加载器,我们为了兼容它们,无法避免ClassLoader被子类覆盖的可能性
因此我们想到一个解决方法,就是在
java.lang.ClassLoader
中添加一个新的protected方法findClass()
- 并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在loadClass()中编写代码
(在双亲委派模型的核心代码中的第④步,这样就做到了妥协,如果父加载器加载失败,则会调用自己的findClass()方法
- 既按照用户意愿去加载类, 又保证符合双亲委派模型)
第二次破坏:
由自身缺陷导致,我们回想一下该模型的好处,无论哪一个类加载器要加载一个类,最终都是委派给处于模型最顶端的启动类加载器进行加载
如果该类加载加载失败,则抛出异常转而让下一层加载器加载(越基础的类由越上层的加载器进行加载)),这样解决了基础类型的不一致问题
但是有一个问题:
- 如果基础类型(被启动类加载器加载的)要调用用户的代码,那怎么办?
针对这个问题,我们引入了线程上下文类加载器(Thread Context ClassLoader)
该加载器可以通过
java.lang.Thread
类的setContext-ClassLoader()方法进行设置如果在创建线程的时候还没有创建,我们就通过它的父线程继承一个
- 如果其父线程甚至整个程序都没有设锅置,那我们就将应用类加载器充当线程上下文类加载器
而这种行为是父类加载器请求子类加载器去完成类加载,也就是变相的打破了双亲委派模型的层次结构逆向使用类加载器
- 已经违背了双亲委派模型的一般性原则
第三次破坏:
用户对程序动态性的追求而导致的。动态性指的是一些很热的名词,比如代码热替换(Hot Swap)、模块热部署(Hot Deployment)等
比如IBM公司的OSGi,实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(
OSGi
中称为Bundle)都有一个自己的类加载器当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换
在OSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构
它的查找顺序只有在开头符合双亲委派模型的原则,其余的类查找都是在平级的类加载器中进行
OSGI在运行期动态热部署上的优势是JDK9以后
Sun/Oracle
公司所提出Jigsaw不能的
- 其只能局限于静态地解决模块间封装隔离和访问控制的问题
线程安全
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步
- 或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的
按照线程安全的安全程度由强至弱来排序,我们可以将Java语言中各种操作共享的数据分为以下五类:
- 不可变
- 绝对线程安全
- 相对线程安全
- 线程兼容
- 线程对立
不可变:
- 不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施
【注】:
final
关键字带来的可见性时曾经提到过这一点:
- 只要一个不可变的对象被正确地构建出来(即没有发生this引用逃逸的情况)
- 那其外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态
Java语言中,如果多线程共享的数据是一个 基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的
- 对于对象,还没有相应的支持方式,API中符合不可变要求的类型有String之外,常用的还有枚举类型及
java.lang.Number
的部分子类如Long和Double等数值包装类型、BigInteger和BigDecimal等大数据类型
- 但同为Number子类型的原子类AtomicInteger和AtomicLong则是可变的
不可变带来的安全性是最直接、最纯粹的
绝对线程安全:
绝对的线程安全能够完全满足我们文章开头定义的线程安全概念(也就是说更加严格 )
- (调用者都不需要任何额外的同步措施”可能需要付出非常高昂的,甚至不切实际的代价)
【注】:在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全
比如
java.util.Vector
是一个线程安全的容器(因为它的add()、get()和size()等方法都是被synchronized修饰的,尽管这样效率不高
- 但保证了具备原子性、可见性和有序性,但是在多线程环境中
- 如果不在方法调用端做额外的同步措施,使用这段代码仍然是不安全(不是绝对线程安全)的
- 假如Vector一定要做到绝对的线程安全,那就必须在它内部维护一组一致性的快照访问才行,每次对其中元素进行改动都要产生新的快照
- 这样要付出的时间和空间成本都是非常大的
相对线程安全 :
相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单次的操作是线程安全的
我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性
【注】:在Java语言中,大部分声称线程安全的类都属于这种类型
- 例如:Vector、HashTable、Collections的
synchronizedCollection()
方法包装的集合等
线程兼容:
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用
【注】:我们平常说一个类不是线程安全的,通常就是指这种情况
- Java类库API中大部分的类都是线程兼容的
线程对立:
线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码
【注】:Thread类的suspend()和resume()方法
- 调用这两个方法的线程存在很大的死锁风险,如今已经废弃
互斥同步:
互斥同步是一种最常见也是最主要的并发正确性保障手段
互斥是实现同步的一种手段,临界区、互斥量和信号量 都是常见的互斥实实现方式
- 互斥是因,同步是果,互斥是方法,同步是目的
【注】:最基本的互斥同步手段就是synchronized关键字、J.U.C包、重入锁ReentrantLock
等待可中断:
- 当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情
公平锁:
- 指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁
- 而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁
指一个ReentrantLock对象可以同时绑定多个Condition对象
在synchronized中,锁对象的wait()跟它的notify()或者notifyAll()方法配合可以实现一个隐含的条件
如果要和多于一个的条件关联的时候,就不得不额外添加一个锁
- 而ReentrantLock则无须这样做,多次调用newCondition()方法即可
非阻塞同步:
互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步
互斥同步属于一种悲观的并发策略,而随着硬件指令集的发展,我们已经有了另外一个选择:基于冲突检测的乐观并发策略
通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了
- 如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止
- 这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步,使用这种措施的代码也常被称为无锁编程
无同步方案:
要保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步与线程安全两者没有必然的联系
同步只是保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及共享数据
- 那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的
两类:
可重入代码:
又称纯代码,指可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身)
而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响
锁优化
高效并发是从JDK 5升级到JDK 6后一项重要的改进项
HotSpot
虚拟机开发团队在这个版本上花费了大量的资源去实现各种锁优化技术
如适应性自旋、锁消除、锁膨胀、轻量级锁、偏向锁等
这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行效率
自旋锁:
前面提到互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入 内核态 中完成
同时,在许多应用上,共享数据的锁定状态 只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得
因此,我们可以让没有请求到锁的线程等一会,但不放弃处理器的执行时间,看看持有锁的线程是否很快释放锁。而这个等待的操作
- 我们让线程执行忙循环(自旋)来实现,这就是所谓的自旋锁
[注]:在JDK 6中就已经改为默认开启,
-XX:+UseSpinning
控制自旋锁的性能分析:
自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间:
- 如果锁被占用的时间很短,自旋等待的效果就会非常好
如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源
- 而不会做任何有价值的工作,这就会带来性能的浪费
因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁
- 就应当使用传统的方式去挂起线程
- 自旋次数的默认值是十次,用户也可以使用参数
-XX:PreBlockSpin
来自行更改
自适应自旋锁(自旋锁的优化):
无论是默认值还是用户指定的自旋次数,对整个Java虚拟机中所有的锁来说都是相同的
而在JDK 6中对自旋锁的优化,引入了自适应的自旋
自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功
- 进而允许自旋等待持续相对更长的时间,比如持续100次忙循环
另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源
锁消除:
锁消除是指虚拟机 即时编译器(
JIT
) 在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除
- (锁消除的主要判定依据来源于 逃逸分析的数据支持))
如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待
- 认为它们是线程私有的,同步加锁自然就无须再进行
锁粗化:
我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步
这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁
大多数情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的
- 那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗
- 因为这时我们需要适当扩大同步块的作用范围
- 比如:在for循环中的同步块放到for循环外
轻量级锁:
轻量级锁是
JDK 6
时加入的新型锁机制,它名字中的轻量级是 相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为重量级锁设计的初衷:
- 不是用来替代重量级锁,而是在没有锁竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗
HotSpot虚拟机的对象头分为两部分:
第一部分:用于存储对象自身的运行时数据,如哈希码、GC分代年龄等
- 这部分数据的长度在32位和64位的Java虚拟机中分别会占用32个或64个比特,称为 Mark Word
- 这部分是实现 轻量级锁和偏向锁 的关键
另外一部分:
- 用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度
[注]:由于对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到Java虚拟机的空间使用效率
Mark Word
被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息
偏向锁:
偏向锁也是JDK 6中引入的一项锁优化措施
- 它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能
如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量
- 那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了
偏:
- 锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取
- 则持有偏向锁的线程将永远不需要再进行同步
启用参数:
-XX:+UseBiased Locking
,JDK 6起HotSpot虚拟机的默认开启)当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为01、把偏向模式设置为1,表示进入偏向模式
同时 使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中
- 如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作
一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束
- 根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为0)
- 撤销后标志位恢复到未锁定(标志位为01)或轻量级锁定(标志位为00)的状态,后续的同步操作就按照上面介绍的轻量级锁那样去执行。
当对象进入偏向状态的时候,
Mark Word
大部分的空间(23个比特)都用于存储持有锁的线程ID了
- 这部分空间占用了原有存储对象哈希码的位置,那原来对象的哈希码怎么办呢?
首先在Java语言里面一个对象如果计算过哈希码,就应该一直保持该值不变(强烈推荐但不强制,因为用户可以重载hashCode()方法按自己的意愿返回哈希码)
而作为绝大多数对象哈希码来源
Object::hashCode()
方法,返回的是对象的一致性哈希码,这个值是能强制保证不变的
- 它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变
因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了
- 而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁
在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的
ObjectMonitor
类里有字段可以记录非加锁状态(标志位为01)下的Mark Word
- 其中自然可以存储原来的哈希码