正如标题所示,本篇笔记是在阅读周志明先生的《深入理解Java虚拟机》的过程中记录的,包括书中关键的知识点和个人的理解心得。
笔记中的“第XXX部分”和“第XXX章”均是指书中的章节,目的是方便阅读的时候,如果遇到困惑或者过感兴趣的部分,可以去原书中锁定具体位置进行深入了解!
(或有不足,还请带着批判的心理去阅读;若有错误,敬请指正!)
1. 第一部分——序章
1.1 第1章
JDK:
- Java程序设计语言
- java虚拟机
- java类库
JRE:
- java类库(准确的说是其中的java SE API子集)
- java虚拟机
2. 第二部分——自动内存管理
2.1 第2章:java内存区域与内存溢出异常🤔
2.1.1 jvm对其管理的内存进行划分
- 程序计数器:记录执行的字节码指令的位置(简单点,可以理解为指令在字节码中的行号)
- 虚拟机栈:生命周期和线程相同,是线程的内存模型,记录线程执行中的方法的栈帧(存储局部变量、操作数栈、动态链接、方法出口(指return返回的地址)等信息)。如果线程的栈深度超出虚拟机允许的深度,就会抛出StackOverFlowError异常。如果栈深度可以继续扩展,但是无法申请到足够的内存就会抛出OutOfMemoryError异常。
栈帧的详情可以参看:
JVM中的虚拟机栈
-
- 本地方法栈:作用和“虚拟机栈”相似,虚拟机栈为java方法(字节码)服务,本地方法栈为虚拟机使用到的本地方法服务(非java编写的方法,比如c++编写的操作硬件的方法),但是一旦用了本地方法(被编译成机器代码而非字节码)就会变得和平台相关。
- 堆:最大的一块区域,存放对象实例,是“垃圾收集器”管理的内存区域(所以有些时候也叫它GC堆)。但从分配内存的角度,里面有可以划分出多个线程私有的分配缓冲区(TLAB),目的都是为了加快内存的申请、方便内存的管理。
- 方法区:存储已被虚拟机加载的类型信息(包括一个类的方法详情、类的静态变量等)、常量、静态变量、及时编译器编译后的代码缓存区等数据,又个别名“非堆”(特意区别于java堆区)
- 运行时常量池:属于方法区的一部分,存放编译时生成的各种字面量和符号引用
- 直接内存:不是jvm运行时数据区的一部分(上面图中所示的那些),而是为了存储new Input/Output处理的数据(像通过Channel或者Buffer方式读取的数据),java堆里只有DirectByteBuffer对象作为这部分内存的引用。带来的好处是避免了数据在“java堆”和“本地堆”之间来回复制。也就是说,该部分内存分配不受java堆大小的限制,但是显然还是受实际硬件内存的限制,依旧可能遇到OutOfMemoryError的异常
2.1.2 对象的创建概览
jvm遇到一条字节码有new指令时,会去常量池中检查该指令的参数是否真的可以定位到一个类的符号引用。并检查符号引用代表的类是否已被加载、解析、初始化。如果没有就必须先执行该类的加载过程(第七章详解这部分),接着就是为新生对象分配内存(具体大小在类加载完后就能确定)。分配好内存还不够,还需要将被分配到内存初始化为”零值“,并设置对象头内的必要信息(年龄代、所属的class类型等)。对于虚拟机而言,一个对象实例创建过程完成了。其引用会被赋给栈帧中对应的变量。(当然还需要执行构造函数,不过这就是后文了)
2.1.3 堆内存分配策略
- “指针碰撞”
这是一种对象内存分配策略,必须建立在jvm堆内存非常规整的基础上——已分配部分和未分配部分泾渭分明,中间用一个指针作为分界点指示器。这样,当分配新内存时,只需要将该指针向未分配区域挪动一段与对象大小相等的距离就可以了!
- “空闲列表“
加入jvm堆内存并不规整,已分配的和未分配的杂糅在一起,那么jvm就需要维护一个列表来记录内存块的使用情况,分配内存的时候就需要挑足够大的空间给对象,然后更新列表信息。
分配策略选择的依据:
关键在于堆内存是否规整,这由垃圾收集器是否带有空间压缩能力决定,如果具有该能力,就可以采用指针碰撞,反之就只能使用空闲列表。
如何保证分配内存是指针挪动的并发问题:
- 对分配内存的动作进行同步处理,保证指针挪动的原子性
- 提前给各自的线程分配一块内存(本地线程分配缓冲——TLAB),让它们在各自的内存区域操作各自的内存划分,直到TLAB不够用了才操作堆内存的指针。
2.1.4 对象定位
java虚拟机规范并没有要求,完全靠虚拟机具体实现而定
主流方式:
- 使用句柄(很常见)
java堆中专门划分一篇区域作为句柄池,引用变量存储的是句柄池中的某一个句柄的地址,句柄里存储对象实例地址和对象类型地址,如下图:
- 直接指针(也很常见,并且HotSpot采用这种)
引用变量直接记录对象实例的地址,这就需要对象实例内存区专门存储一下对象类型数据的地址,如下图:
2.1.5 OutOfMemoryError与StackOverFlowError
Out…Error报错说明内存不足,Stack…Error说明栈的深度超出限额
对于不同的jvm,即使是同一种场景也可能抛出不同的错误
- 对于允许栈容量动态扩展的jvm:
- 如果分配栈内存失败会抛出Out…Error
- 如果栈内存分配成功,但是新的栈帧不足以加入到栈内存中(扩容失败),也会抛出Out…Error
- 如果只是栈深度超出限额,会抛Stack…Error
- 对于不允许栈容量扩展的jvm:
- 如果栈内存分配失败会抛出Out…Error
- 重点!!!:如果栈内存分配成功,但是新的栈帧无法加入到栈内存中(不允许扩容),抛出的不是Out…Error,而是Stack…Error,这也容易理解,毕竟只是该线程的栈内存不够了,虚拟机的栈内存或许还绰绰有余,所以抛出Stack…Error更合理
- 如果栈深度超出限额,会抛出Stack…Error
2.2 第3章:垃圾收集器与内存分配策略🤔
栈帧的大小在编译期间就可以完全确定,所以它的入栈出栈造成对内存空间变化很容易处理;而java堆里面的对象数量和具体大小会在运行时不断动态变化,所以java堆需要由垃圾收集器专门“照顾”。
2.2.1 如何判断对象不再被使用?
- 引用计数器法
为对象添加一个引用计数器,每当有一个地方引用它时就给计数器+1;引用失效时,计数器-1;任何时刻,计数器为0就表示“对象不再被使用”
优点:简单高效
缺陷:对于循环引用的对象无法起到排查的效果,比如A、B互相引用,但是之后并不被使用,计数器因为不是0而无法将它们纳入可回收部分。
- 可达性分析算法
Java、C#都使用这种算法判定对象是否存活
算法基本思路是,从一系列可以作为“GC Roots”的根对象出发,通过引用关系向下搜索,搜索过大路径称为“引用链”,如果一个对象到各个GC Roots之间都不存在引用链,那么该对象就是不再被使用的。
可以作为“GC Roots”的对象有:- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中,类的静态属性引用的对象
- 方法区中常量引用的对象,比如字符串常量池里的引用
- 所有被同步锁持有的对象
- ……(略)
2.2.2 方法区的回收
- 常量回收
只要没有对象引用该常量就可以回收
- 类型回收
需要满足以下三个条件:- 该类及其子类的实例都已被回收
- 加载该类的类加载器已被回收
- 该类的Class对象没有在任何地方被引用,即不存在通过反射机制访问到该类的可能
2.2.3 垃圾收集算法
从判定对象消亡的角度,垃圾收集算法可以划分为“引用计数垃圾收集”和“追踪式垃圾收集”,jvm采用后者!
- 分代收集理论
- 弱分代假说:绝大多数对象“朝生夕灭”
- 强分代假说:熬过越多次垃圾收集过程的对象越难消亡
所以java堆就进一步划分出了不同的区域,如果一个区域内的对象都是“朝生夕灭”的那种,垃圾收集器只需要关注如何其中还存活的(不必去标记大量要被回收的);反之,对于难以消亡的对象集中区,虚拟机可以使用较低的频率回收该区域(对应强分代假说)。
有了java堆区域划分后,就有了“Minor GC”(新生代的垃圾收集)、“Major GC”(老年代的垃圾收集)、“Full GC”(整个java堆和方法区的垃圾收集)这样的回收类型划分,也有了“标记-清除算法”、“标记-复制算法”、“标记-整理算法”等针对性的垃圾收集算法!!可以说,一切始于分代收集理论。
- 标记-清除算法
最早、最基础的垃圾收集算法,是后面大多数收集算法的基础。
正如其名,该算法分为“标记”和“清除”两个阶段。
标记需要回收的对象,然后统一回收;或者反过来,标记所有存活的对象,然后统一回收没有被标记的对象。
标记过程就是“判定是否还被使用”的过程(引用计数器法、可达性分析法)。
缺点:- 执行效率不稳定(受到java堆中“垃圾”对象的数量影响)
- 空间内存碎片化(清除操作执行后导致的内存空间不连续)
- 标记-复制算法(常用于Minor GC)
常简称“复制算法”
解决“标记-清除算法”面对大量可回收对象时执行效率低、碎片化的问题
过程:将java堆内存平分成两部分,每次只用其中一部分,只有当该部分不够用的时候才执行垃圾回收过程,然后将活着的对象复制到另一块上去,以此保证内存空间的连续、抑制回收对象数量膨胀。图示如下:
缺点:空间浪费很大,实际上的可用java堆内存只有一半
优化:很多jvm将该算法应用到“新生代”的垃圾回收。IBM统计量化的结果显示,新生代中有98%熬不过第一轮的收集,所以大可不必将新生代的java堆按照1:1划分。现代的新生代内存划分实际采用的是Appel式。Appel式回收:
新生代内存划分成1块较大的Eden空间和2块较小的Survivor空间。它们的占比是8:1:1。每次分配内存只是用Eden和其中一块Survivor,所以空间利用率为90%。当发生Minor GC的时候,将Eden和Survivor中仍存活的对象全部复制到另一个没有用到的Suvivor块上,然后清除Eden和旧的Survivor。如果存活的对象体积超出一块Survivor的容量,那么这些对象会通过分配担保机制直接进入老年代。 - 标记-整理算法(常用于Major GC)
“标记-复制算法”在面临对象存活较多的时候(老年代显示容易出现这种情况),需要进行较多的复制操作,更关键的是,如果不想浪费50%空间(Appel式回收)就需要额外的空间做分配担保,所以老年代不使用”标记-复制算法”。
过程:当需要进行垃圾清理的时候,标记过程和“标记-清除算法”一样,然后将所有存活对象向内存空间的一端移动并聚拢,最后清除掉边界外的内存。图示如下:
2.2.4 具体的垃圾收集器
前面提到的垃圾收集算法是理论,这里列举一些将理论化为实现的垃圾收集器
新生代的收集器:
- Serial(标记-复制算法)
- Parallel Scavenge(标记-复制)
- ParNew(标记-复制算法)
- ……
老年代的收集器:
- Serial Old(标记-整理算法)
- Parallel Old(标记-整理算法)
- CMS(内存碎片化不严重的时候采用标记-清理算法,反之采用标记-整理算法)
- ……
只有ParNew才能和CMS配合
常见垃圾收集器参数设置内容:
- 选取什么垃圾收集器组合
- 新生代中Eden和Survivor的内存占比
- 是否允许分配担保失败
- 新生代转老年代的年龄阈值
- 直接晋升老年代的对象大小要求
- ……
2.2.5 新生代与老年代
- 空间划分:新老空间占比默认为1:2
- 采用的算法:
- 新生代常用“标记-复制”算法
- 老年代常用“标记-整理”算法
- 新生代转变成老年代:新生代中存活下来的对象的年龄会+1,当到达临界点(默认15)就会升级为老年代,大对象也会直接进入老年代(避免每次都导致Survivor不足,被迫启用分配担保机制);但也不是一定要到达阈值要求才能进入老年代,还有动态对象年龄判定机制——当年龄<=n的对象大小总和超过Survivor一半时,年龄>n的对象可以直接进入老年代
- 老年代空间占用到达某个值之后就会出发全局垃圾回收,Full GC一般使用“标记-整理”算法。
- Full GC也不能为内存分配腾出足够空间的时候,就会抛出OutOf MemoryError错误
3. 第三部分——虚拟机之执行子系统
3.1 第6章:类文件结构🤔
jvm其实不光具有平台无关性(中立于操作系统),还具有语言无关性(很多其他语言也可以运行在jvm上,比如Kotlin、JPython),一切的中立性都来源于jvm只关注编译的文件是否是Class文件。
3.2 第7章:虚拟机类加载机制🤔
转到上面的/对象的创建概览
概念说明:
jvm把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
- 类对象的生命周期
加载->验证->准备->解析->初始化->使用->卸载
其中,验证、准备、解析统称为“连接”。
- 加载
- 通过一个类的全限定名来定位此类的二进制字节流(字节流来源随意,比如Class文件、ZIP压缩包、网络、运行时计算生成——动态代理技术等)
- 将字节流所代表的静态存储结构转化为方法区中运行时的数据结构
- 在内存中生成一个代表该类的java.lang.Class对象
数组类的加载并不依靠类加载器,而是jvm直接在内存中动态构造出来,但是其中的元素类型仍需要类加载器。加载过程和“连接”过程的部分动作其实是交叉进行的,连接阶段可能在加载尚未完成时就已经开始(比如字节码文件格式的验证动作)。
- 验证
大致包括如下四个阶段:- 文件格式验证
- 元数据验证:确保不出现与《java语言规范》定义相悖的内容
- 字节码验证
- 符号引用验证
- 准备
正式为“类变量”(类的静态变量)分配内存并设置初始值(零值)的阶段,真实值需要等到初始化阶段;如果是类中的final数据,则会一开始就赋上真实值。
- 解析
jvm将常量池内的符号引用替换为直接引用的过程
- 初始化
开始真正执行类中编写的java代码,主导权从jvm转到了应用程序
准备阶段绝大多数类变量赋与的仅仅是系统“零值”,这一阶段会根据程序内容为类变量赋与真实值。
初始化过程会“加锁”,避免并发情况下重复初始化。
3.2.1 类加载器
Java虚拟机设计团队将“通过一个类的全限定名来获取类的二进制字节流”这个动作(“加载”阶段)放在了jvm的外部去实现,以便让具体的jvm实现者决定如何获取(Class文件、网络、ZIP等),而实现这个动作的代码被称作“类加载器”!
类加载器只用于实现类的“加载”动作,但起的作用却远不止于此。
每一个类都对应一个属于它的类加载器,这个类加载器拥有属于它的类名称空间。
只有两个类的类加载器是同一个,这两个类才可能“相等”(equals、isInstance等方法),反之必然不等(即使它们真的是同一个class类型)。
- 三种系统提供的类加载器
- 启动类加载器:c++语言实现,是jvm的一部分,无法在java程序中直接引用,负责加载lib目录下的合法类库
- 扩展类加载器:java语言实现,可以在java程序引用,负责加载ext目录下的扩展类库
- 应用程序类加载器:java语言实现,java程序在加载用户定义的类时默认使用的类加载器。
- 双亲委派模型
下图所示的各类加载器之间的层次关系被称为“双亲委派模型”。
该模型要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器,这里的父子关系一般不是“继承”关系,而是“组合”关系,来实现代码复用
注:该模型并非强制要求,仅仅是一种偏向建议性的“最佳实践”。
该模型的工作流程:每一个类加载器在收到类加载请求时,自己不会第一时间去执行,而是先交给父类加载器,只有当父类加载器反馈无法完成这个请求时,子类才会去尝试自己完成 - 采用双亲委派模型来加载类的好处
最大的优势是保证了java程序的稳定运作!因为在这种模式下,类具有了和类加载器的绑定关系,同一个类(比如java.lang.Object)无论在何种类加载器环境下,最终都会交由与其相绑定的类加载器去加载,这就保证了类加载过程的不变性。反之,就可能会出现类由不同的加载器导致的混乱(因为两个对象的类型是否相同最终由“加载它的类加载器+类对象类型”决定),甚至会导致java最基础的类的功能和行为收到影响。
- 破坏双亲委派模型
(不做赘述,详见书本“7.4.3节”)
4. 第五部分——高效并发
4.1 第12章:Java内存模型与线程🤔
4.1.1 java内存模型
java内存模型出现的目的:为了屏蔽底层硬件和系统内存访问的差异,实现java程序在不同平台下达到同样的内存访问效果。(避免像c/c++那样在代码中直接和底层硬件、操作系统内存交互)其真实的内存操作是靠平台的jvm实现的。
- 主内存与工作内存
Java内存模型规定所有的变量(包括实例字段、静态字段、构成数组对象的元素,但是不包括局部变量、方法参数,因为后者是线程私有,不会共享,不存在竞争)都存储在主内存(类比实际的内存),每一条线程拥有自己的工作内存(类比实际的高速缓存),线程的工作内存包含线程使用的变量的主内存副本。
关于副本理解:
线程对变量的所有操作都必须在自己的工作内存中进行,而不能直接操作主内存的数据;各个线程也不能直接访问彼此的工作内存中的变量,只能通过主内存传递。 - java内存模型和实际计算机的内存模型对比(高度类似)
4.1.2 内存间的交互操作
思考问题:
如何从主内存拷贝到工作内存?
如何从工作内存同步回主内存?
关于上述的实现,java内存模型定义了8种操作来完成:
- lock:
- 作用于“主内存”
- 将变量标识为一条线程独占的状态
- unlock:
- 作用于“主内存”
- 对一个被lock的变量”解锁“
- read:
- 作用于“主内存”
- 把一个变量的值从主内存传输到线程的工作内存,一边随后的load使用
- load(载入):
- 作用于“工作内存”
- 把read到线程工作内存的变量值放入到工作内存的变量副本中
- use:
- 作用于“工作内存”
- 把工作内存中的一个变量的值传递给执行引擎,当虚拟机遇到一条需要使用变量的值的字节码指令时,会执行该操作
- assign(赋值):
- 作用于“工作内存”
- 把从执行引擎接收的值赋给工作内存的变量,当虚拟机遇到一条给变量赋值的字节码指令时,会执行该操作
- store:
- 作用于“工作内存”
- 把工作内存中的变量值传递到主内存,以便后续的write使用
- write:
- 作用于“主内存”
- 把store操作传来的值存放到主内存的变量中
对8种操作的要求:(详见书本12.3.2)
后面对操作改进的过程,Java设计团队将8种简化为了4种:read、write、lock、unlock
4.1.3 volatile型变量
- volatile修饰的变量的特性
- 一条线程修改了这个变量的值时,新值对于其他线程来说可以立即得知。简单来说,就是线程改变了变量值时会被强制将新值同步到主内存,其他线程在每次使用前都需要立即从主内存重新读取一次。
-
- 禁止指令重排序优化:普通的变量只能保证方法执行过程中所有依赖赋值的结果都是正确的,但不能保证赋值的顺序始终和代码一致。volatile会在关键的赋值操作后面加一个“lock指令”作为内存屏障,防止屏障后面的指令被重排到屏障前面,同时lock指令会引起本处理器缓存写入内存中,使得volatile修饰的变量立即可见。
更多有关指令重排的说明可以参考:
But!volatile的线程间的“可见性”不能消除并发问题
- 可以使用volatile的前提(指不会造成并发问题)
- 运算结果不依赖变量的当前值,或者能够确保只有单一线程修改变量的值
- 变量不需要与其他的状态变量共同参与不变约束。
- “先行发生”原则
概念:先行发生原则描述的是两个操作之间的偏序关系。如果操作A是”先行发生“于操作B的,就表明操作A对变量的影响能够被操作B观察到!
java中有内存模型下,有一些“天然的”先行发生关系,详情可以参看《深入理解java虚拟机》的12.3.6节(先行发生原则)。
volatile修饰的变量的写操作能够保证”先行发生“于后面对这个变量的读操作,这里的“后面”指的是时间上的“后”。
4.1.4 原子性、可见性、有序性
整体上看,java内存模型是围绕着并发过程中如何处理“原子性、可见性、有序性”这三个特征来建立的。
- 原子性
java内存模型中的read、load、store、write、use、assign来直接保证原子性操作,我们大致可以认为基本数据类型(long和double非原子)的访问、读写都是具备原子性的。更大范围的原子性可以通过lock、unlock实现(体现在字节码中就是monitorenter和monitorexit,体现在java代码中就是synchronized关键字)。
- 可见性
可见性其实就是当一个线程修改了一个共享变量时,其他线程可以立即得知这个修改。java内存模型的可见性的实现过程其实就是在变量被修改后立刻刷新到主内存,并在读取该变量时立刻从主内存中取值。java代码中不仅仅volatile可以做到这一点,还有synchronized和final也可以。
synchronized实现这一点本质是最后会有一个unlock操作,而JMM要求对一个变量执行unlock操作前,必须先将此变量同步回主内存。
final实现这一点靠的是变量在构造后对不可改变特性,不需要同步,其他线程看到的始终是最新、有效的值。
- 有序性
java程序中天然的有序性可以总结为:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程, 所有的操作都是无序的。前半句是指“线程内似表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
4.1.5 Java与线程
- 线程实现
- 内核线程实现:又被称为轻量级进程(LWP),每个LWP下面由一个内核线程支持。
- 用户线程实现:线程完全建立在用户空间的线程库上,不需要内核帮助。
- 混合实现:即存在用户线程,又存在内核线程,LWP作为连接用户线程和内核线程的桥梁
- java线程的实现
HotSpot采用的是”内核线程实现“,将线程的调度全权交给操作系统,自己完全不干涉。
- java线程调度
线程调度指的是操作系统为线程分配处理器使用权的过程,主要有两种:- 协同式:线程完成工作后通知系统换下个线程来执行
- 抢占式:线程执行多久完全由系统控制。
java线程调度采用的是“抢占式”,并且设置了10个优先级来大致控制线程运行的时间分配,优先级越高的线程越容易获取处理器的拥有权。
- 线程状态转换
java语言定义了6种线程状态:- 新建(New):创建后尚未启动
- 运行(Runnable):其实包含了操作系统线程状态定义中的”运行“和”就绪“,所以又可能正在等待被分配时间
- 无限期等待(Waiting):需要被其他线程显示唤醒,否则永远不会被分配执行时间,常用的线程的wait()方法就会让线程转到这种状态。
- 有限期等待(Timed Waiting):过一段时间后会被自动唤醒,线程在调用方法wait()并传入Timeout参数后进入该状态。
- 阻塞(Blocked):阻塞状态是指线程正在等待一个”排他锁“被释放
- 结束:线程执行完毕
4.2 第13章——线程安全与锁优化🤔
4.2.1 线程安全等级划分
- 不可变:对象是final或者保证一定不会产生状态变化,这种对象天然具有线程安全的特性
- 绝对线程安全:如果对象不是“不可变”,这是一种很难达到的一种理想状态
- 相对线程安全:java中所谓的线程安全(Vector、HashTable)就是这个层级,能保证对象单次操作是线程安全的,但是对于一定顺序连续调用依旧可能出错。
- 线程兼容:对象本身不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发状态下安全使用
- 线程对立:指无论是否采取同步措施,都无法做到并发安全。java天生支持多线程的特性,这种情况实在很少见。
4.2.2 线程安全的实现方法
- 互斥同步(阻塞同步)
“互斥“是方法,”同步”是结果
常见的synchronized就是互斥同步关键字,经Javac编译后会在同步块前后加上monitorenter、monitorexit两个字节码指令。
synchronized的特点:- 同步块对于同一个线程来说是可重入的,也就是说持有锁的线程可以重复进入同步块也不会出现将自己锁死的情况。
- 同步块在持有锁的线程执行完毕并释放锁之前,后面等待锁的线程会被无条件阻塞。无法强制线程释放锁,也无法强制等待锁的线程中断等待或超时退出!
Lock是java.util.concurrent包下的一个接口,是java全新的同步手段,ReentrantLock是Lock的一种最常见的实现,和synchronized很类似,同一线程也可重入。
ReentrantLock相比于synchronized的高级功能:
-
- 等待可中断:等待锁的线程可以选择放弃等待
- 可实现公平锁:公平锁指多个线程等待同一个锁时,会按照申请锁的时间先后获取到锁,ReentrantLock可以手动设置为公平锁(但性能会急剧下降)
- 锁可以绑定多个条件 :一个ReentrantLock可以绑定多个Condition对象。
在JDK6以后,synchronized的性能已经被优化,与ReentrantLock相差无几;从能上看,后者是前者的超集,所以是否需要抛弃synchronized呢?
synchronized的优势:
-
- 代码编写简单且清晰
- Lock需要确保在finally代码块中释放,否则一旦持有锁的线程抛异常,锁对象就可能永远不会被释放了
- 从长远的角度来看,synchronized更容易被虚拟机优化。
- 非阻塞同步
互斥同步(阻塞同步)面临的主要问题是线程阻塞和唤醒带来的性能开销。
互斥同步的加锁态度是“悲观”的,一定要持有锁才敢操作数据;而非阻塞同步是“乐观”的,先操作,没有冲突万事大吉,有冲突再进行其他补偿措施(例如不断重试)。
非阻塞同步的措施也常被称为“无锁编程”。
非阻塞同步依赖的是硬件指令的进步(如支持CAS原子操作) - 无同步方案
如果一个方法不涉及共享数据,那么它天然就不需要任何同步措施保证其正确性。
可以用一个比较简单的原则判读代码是否具备“可重入性”:如果能保证输入相同数据一定能返回相同的结果,那么它就是可重入的!也是线程安全的!
4.2.3 锁优化
- 自旋锁与自适应自旋
互斥同步对性能最大的影响来自于线程被阻塞时的“挂起”和“恢复”的操作。
所以就有了自旋锁的概念:不让等待锁的线程挂起,而是让它在另一个处理器上“忙循环”,直到锁被释放后获得锁才终止循环并开始执行任务。
为了避免长时间的“自旋”造成处理器资源的浪费,一般对循环次数有默认限制,超出限制后仍旧采用“挂起”->“唤醒”的流程。
自旋锁的改进——自适应的自旋:自旋时间的限制不再固定。规则是,对于之前通过“自旋”多次成功等到锁对象的情况,虚拟机便会认为该锁对象通过“自旋”等到的概率较高,会给予正在等待的线程更高的自旋时间上限;反之,极端一点,虚拟机甚至会让等待线程直接“挂起”,因为“自旋”期间等到锁的概率太低了。
- 锁消除
指虚拟机即时编译器在运行时,发现某些要求同步的代码根本不可能存在共享数据的竞争,那么就会消除相应的锁。
锁消除判定的依据来源于逃逸分析的数据支持。
- 锁粗化
如果一系列连续的操作都对一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那么即使没有线程竞争,频繁的互斥同步操作也会导致不必要的性能损耗。
如果虚拟机探测到一连串零碎的操作都对一个对象加锁,就会把这个加锁同步的操作扩展(粗化)到操作序列的外部。 - 轻量级锁
“轻量级”是相对于操作系统“互斥量”实现的传统锁而言的,不是为了代替“重量级锁”,而是为了在没有多线程竞争的前提下,减少传统重量级锁使用操作系统互斥量产生的性能开销。
轻量级锁(还有下面的“偏向锁”)都需要用到“对象头”里的信息,细节如下图:
在线程获取锁对象的时候,如果锁对象没有其他线程获取就把锁对象标志为标记为“01”,并用CAS操作修改锁对象的“Mark Word”指向当前线程栈帧中的“锁记录”(对锁对象原有Mark Word的拷贝),此时完成了轻量级锁的加锁过程,线程可以执行同步代码块了!
如果获取锁对象的时候,发现锁对象已经被其他线程获取,就检查锁对象的Mark Word指向的是不是自己,如果是的话,说明锁对象目前是已经被自己获取到的轻量级锁,可以继续执行同步代码块;反之,就需要将将锁对象升级为重量级锁,锁对象的Mark Word指向的是“互斥量”。
解锁过程也是用的CAS操作,会尝试将锁对象的Mark Word改回原来的信息。如果成功解锁,说明这个锁还是轻量级锁;反之,这个锁已经成了重量级锁,线程不能按照轻量级锁的方式解锁了,而是按照重量级锁的方式,接着唤醒正在等待锁的进程。
轻量级锁能提升程序同步性能的依据是“绝大部分的锁,在整个同步周期内是不存在竞争的”这一经验法则。当没有竞争时确实更快(省去了互斥量的开销),反之会更慢(多了CAS操作)。
- 偏向锁
目的是消除数据在无竞争情况下的同步原语。
相比于“轻量级锁”是为了在无竞争情况下使用CAS操作避免使用互斥量,那么“偏向锁”就是为了在无竞争情况下吧整个同步都省掉,连CAS操作都不做!。
含义:偏向锁是指该锁会偏向第一个获取它的线程,如果后续一直没有其他线程竞争锁对象,那么持有偏向锁的线程永远不需要再进行同步。
对于计算过一次哈希码的对象,它永远无法进入偏向模式(因为偏向模式下线程ID会占用原本哈希值的位置)
对象头里的epoch和对象所属类中的epoch不同则表示偏向锁现在没有被锁定关于偏向锁的判定可以参考文章:
偏向锁流程解析
…
哇哦,牛的
୧(๑•̀⌄•́๑)૭