Synchronized笔记
1. Java内存模型(JMM)
Java内存模型是一套规范,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,Java内存模型是对共享数据的可见性、有序性、和原子性的规则和保障。
2. synchronized 保证三大特性[可见性、有序性、和原子性]
synchronized能够保证在同一时刻最多只有一个线程执行该段代码,以达到保证并发安全的效果。
synchronized(锁对象) {
//受保护的资源
}
2.1 使用synchronized保证原子性
public class TestAtomicity {
static int number = 0;
public static void main(String[] args) throws InterruptedException {
Runnable increment = () -> {
for (int i = 0; i < 1000; i++) {
//使用synchronized进行锁
synchronized (TestAtomicity.class) {
number++;
}
}
};
List<Thread> ts = new ArrayList<>();
for (int i = 0; i < 50; i++) {
Thread t = new Thread(increment);
t.start();
ts.add(t);
}
//等待每一个线程执行完毕
for (Thread t : ts) {
t.join();
}
System.out.println("number = " + number);
}
}
输出结果:
number = 50000
synchronized保证了number变量的原子性,使得在同一时间内,只会有一个线程对number进行自增操作,就不会出现线程安全问题
2.2 使用synchronized保证可见性
实现的原理:当我们执行synchronized的时候,会对应lock的原子操作,会刷新主内存中共享变量的值
public class TestVisibility {
private static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
Thread t1 = new Thread(()->{
while (run) {
//使用synchronized关键字进行解决
synchronized (obj) {
}
}
});
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(()->{
run = false;
System.out.println("时间到,线程2设置为false");
});
t2.start();
}
}
输出结果:
时间到,线程2设置为false
其他的解决方法:
-
将run变量标注为volatile类型 : private static volatile boolean run = true;
-
在while(run)内部输入打印一句话,底层System.out.println()也是使用synchronized关键字的
2.3 使用synchronized保证有序性
使用synchronized以后还是会发生重排序问题,但是会保证只有一个线程会进入我们的同步代码块中,也就能保证我们单线程的有序性!!
重排序的作用
为了保证程序的执行效率,编辑器和cpu会对程序中代码进行重排序
as-if-serial语义
as-if-serial语义的意思是:不管编译器和cpu如何进行重排序,必须保证在单线程情况下程序的结果是正确的
synchronized保证有序性的原理,我们加synchronized后,依然会发生重排序,只不过,我们有同步代码块,可以保证只有一个线程执行同步代码中的代码。保证有序性
不能重排序的情况
- 写后读
- 写后写
- 读后写
3. Synchronized的特性
3.1 Synchronize的可重入性
一个线程可以多次执行synchronized,重复获取同一把锁。
public class TestReentrancy {
public static void main(String[] args) {
Runnable runnable = new Runnable() {
@Override
public void run() {
synchronized (TestReentrancy.class) {
System.out.println("第一层...");
synchronized (TestReentrancy.class) {
System.out.println("第二层...");
}
}
}
};
new Thread(runnable).start();
new Thread(runnable).start();
}
}
输出结果 :
第一层...
第二层...
第一层...
第二层...
原理 :
synchronized的锁对象中有一个**计数器(**recursion变量),会记录线程获得几次锁,在执行完同步代码块时,计数器的数量会减1,直到计数器的数量减至0,此时就会释放锁
可重入性的好处
- 可以避免死锁
- 可以让我们更好的封装代码
3.2 Synchronized的不可中断特性
指的是一个线程获得锁以后,另一个线程想要获取锁,必须处于阻塞或者等待状态,如果第一个线程不释放锁,第二个线程就会一直处于阻塞或者等待状态,不可以中断的!!!
public class TestUnInterrupted {
private static Object object = new Object();
public static void main(String[] args) throws InterruptedException {
Runnable run = () -> {
synchronized (object) {
String name = Thread.currentThread().getName();
System.out.println(name + "进入同步代码块");
try {
Thread.sleep(888888);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
//先开启一个线程来执行同步代码块
Thread t1 = new Thread(run);
t1.start();
Thread.sleep(1000);
//后开启一个线程来执行同步代码块(阻塞状态)
Thread t2 = new Thread(run);
t2.start();
//停止第二个线程
System.out.println("停止线程前......");
t2.interrupt(); //打断第二个线程
System.out.println("停止线程后......");
System.out.println(t1.getState());
System.out.println(t2.getState());
}
}
输出结果:
Thread-0进入同步代码块
停止线程前......
停止线程后......
TIMED_WAITING
BLOCKED
其中我们的ReentrantLock可以是不可中断的,同时也可以实现中断的:
- **lock()**方法是不可中断的
- **trylock(time)**方法是可中断的
4. Synchronized原理
4.1 javap反汇编
public class TestAtomicity {
static int number = 0;
public static void main(String[] args) throws InterruptedException {
Runnable increment = () -> {
for (int i = 0; i < 1000; i++) {
//使用synchronized进行锁
synchronized (TestAtomicity.class) {
number++;
}
}
};
List<Thread> ts = new ArrayList<>();
for (int i = 0; i < 50; i++) {
Thread t = new Thread(increment);
t.start();
ts.add(t);
}
//等待每一个线程执行完毕
for (Thread t : ts) {
t.join();
}
System.out.println("number = " + number);
}
}
我们对于上述的代码进行反汇编(使用javap -v -p Xxx.class):
其中, 可能的选项包括:
-help --help -? 输出此用法消息
-version 版本信息
-v -verbose 输出附加信息
-l 输出行号和本地变量表
-public 仅显示公共类和成员
-protected 显示受保护的/公共类和成员
-package 显示程序包/受保护的/公共类
和成员 (默认)
-p -private 显示所有类和成员
-c 对代码进行反汇编
-s 输出内部类型签名
-sysinfo 显示正在处理的类的
系统信息 (路径, 大小, 日期, MD5 散列)
-constants 显示静态最终常量
-classpath <path> 指定查找用户类文件的位置
-bootclasspath <path> 覆盖引导类文件的位置
输出反汇编结果:
private static void lambda$main$0();
descriptor: ()V
flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=2, locals=3, args_size=0
0: iconst_0
1: istore_0
2: iload_0
3: sipush 1000
6: if_icmpge 38
9: ldc #22 // class com/feng/synchronizedProblem/TestAtomicity
11: dup
12: astore_1
13: monitorenter
14: getstatic #18 // Field number:I
17: iconst_1
18: iadd
19: putstatic #18 // Field number:I
22: aload_1
23: monitorexit
24: goto 32
27: astore_2
28: aload_1
29: monitorexit
30: aload_2
31: athrow
32: iinc 0, 1
35: goto 2
38: return
Exception table:
from to target type
14 24 27 any
27 30 27 any
LineNumberTable:
line 13: 0
line 14: 9
line 15: 14
line 16: 22
line 13: 32
line 18: 38
LocalVariableTable:
Start Length Slot Name Signature
2 36 0 i I
StackMapTable: number_of_entries = 4
frame_type = 252 /* append */
offset_delta = 2
locals = [ int ]
frame_type = 255 /* full_frame */
offset_delta = 24
locals = [ int, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
frame_type = 250 /* chop */
offset_delta = 5
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: iconst_0
1: putstatic #18 // Field number:I
4: return
LineNumberTable:
line 10: 0
}
其中,在我们的number进行++操作前后:
13: monitorenter
14: getstatic #18 // Field number:I
17: iconst_1
18: iadd
19: putstatic #18 // Field number:I
22: aload_1
23: monitorexit
monitorenter指令:
每一个对象都会和一个监视器monitor关联。监视器被占用的时候会被锁住,其他的线程就无法来获取该monitor。
当JVM执行某一个线程的某一个方法内部的monitorenter的时候,它会尝试去获取当前对象对应的monitor的所有权,其过程是:
- 如果monitor的进入数为0,则该线程可以进入monitor,并将monitor的进入数置为1,当前线程称为monitor的owner(所有者)
- 如果线程已经拥有了monitor的所有权,允许它重入monitor,则进入monitor的进入数加1
- 如果其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程就会阻塞,直到monitor的进入数变成0,才会重新尝试获取该monitor的所有权
其中对于monitor监视器对象:
- 我们的synchronized的锁对象会关联一个monitor对象,这个monitor对象不是我们主动创建的,而是JVM的线程执行到这个同步代码块,会发现锁对象没有monitor,就会为其创建一个monitor。其中monitor中有两个重要的成员变量:
- owner:表示拥有这个锁的线程
- recursions:记录线程拥有锁的次数,当一个线程拥有monitor后,其他线程只能进行等待
monitorexit指令:
monitorexit插入在方法结束处和异常处,JVM保证每一个monitorenter必须有对应的monitorexit,同时当synchronized的同步代码块发生异常,也会执行monitorexit,进行锁的释放!
注意:
- 其中我们用synchronized修饰的同步方法在反汇编以后,会增加一个ACC_SYNCHRONIED修饰。其中会隐式地调用monitorenter和monitorexit。在执行同步方法前会调用monitorenter,在执行完同步方法后会调用monitorexit。
4.2 面试题:synchronized和Lock的区别
- synchronized是一个关键字,而Lock是一个接口
- synchronized会自动释放锁,而Lock必须手动的释放锁
- synchronized是不可中断的,而Lock可以中断,也可以不中断
- 通过Lock可以知道线程有没有拿到锁,而synchronized不能知道
- synchronized能够锁住方法和代码块,而Lock只能锁住代码块
- Lock可以使用读锁来提高多线程的效率
- synchronized是非公平锁,而ReentrantLock可以控制是否为公平锁
4.3 深入JVM源码
我们在HotSpot虚拟机中,monitor是由ObjectMonitor实现的。其源码是用**c++**来实现的,位于HotSpot虚拟机源码ObjectMonitor.hpp文件中(src/share/vm/runtime/objectMonitor.hpp)。ObjectMonitor主要数据结构如下:
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0; // 线程的重入次数
_object = NULL; // 存储该monitor的对象
_owner = NULL; // 标识拥有该monitor的线程
_WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL;
_succ = NULL;
_cxq = NULL; // 多线程竞争锁时的单向列表
FreeNext = NULL;
_EntryList = NULL; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0;
_SpinClock = 0;
OwnerIsThread = 0;
}
其中:
-
_owner:初始时为NULL。当有线程占有该monitor时,owner标记为该线程的唯一标识。当线程释放monitor时,owner又恢复为NULL。owner是一个临界资源,JVM是通过CAS操作来保证其线程安全的。
-
_cxq:竞争队列,所有请求锁的线程首先会被放在这个队列中(单向链接)。_cxq是一个临界资源,JVM通过CAS原子指令来修改_cxq队列。修改前_cxq的旧值填入了node的next字段,_cxq指向新值(新线程)。因此_cxq是一个后进先出的stack(栈)。
-
EntryList:_cxq队列中有资格成为候选资源的线程会被移动到该队列中。
-
_WaitSet:因为调用wait方法而被阻塞的线程会被放在该队列中。
每一个Java对象都可以与一个监视器monitor关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被synchronized圈起来的同步方法或者代码块时,该线程得先获取到synchronized修饰的对象对应的monitor。
我们的Java代码里不会显示地去创造这么一个monitor对象,我们也无需创建,事实上可以这么理解:monitor并不是随着对象创建而创建的。我们是通过synchronized修饰符告诉JVM需要为我们的某个对象创建关联的monitor对象。每个线程都存在两个ObjectMonitor对象列表,分别为free和used列表。同时JVM中也维护着global locklist。当线程需要ObjectMonitor对象时,首先从线程自身的free表中申请,若存在则使用,若不存在则从global list中申请。ObjectMonitor的数据结构中包含:_owner、_WaitSet和_EntryList,它们之间的关系转换可以用下图
表示:
monitor竞争:
- 通过CAS尝试把monitor的owner字段设置为当前线程。
- 如果设置之前的owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行recursions ++ ,记录重入的次数。
- 如果当前线程是第一次进入该monitor,设置recursions为1,_owner为当前线程,该线程成功获得锁并返回。
- 如果获取锁失败,则等待锁的释放。
monitor等待:
- 当前线程被封装成ObjectWaiter对象node,状态设置成ObjectWaiter::TS_CXQ。
- 在for循环中,通过CAS把node节点push到**_cxq列表**中,同一时刻可能有多个线程把自己的node节点push到_cxq列表中。
- node节点push到_cxq列表之后,通过自旋尝试获取锁,如果还是没有获取到锁,则通过park将当前线程挂起,等待被唤醒。
- 当该线程被唤醒时,会从挂起的点继续执行,通过ObjectMonitor::TryLock 尝试获取锁。
monitor释放:
退出同步代码块时会让_recursions减1,当 _recursions的值减为0时,说明线程释放了锁。
monitor是重量级锁:
可以看到ObjectMonitor的函数调用中会涉及到Atomic::cmpxchg_ptr,Atomic::inc_ptr等内核函数,执行同步代码块,没有竞争到锁的对象会park()被挂起,竞争到锁的线程会unpark()唤醒。这个时候就会存在操作系统用户态和内核态的转换,这种切换会消耗大量的系统资源。所以synchronized是Java语言中是一个重量级(Heavyweight)的操作。
从上图可以看出,Linux操作系统的体系架构分为:用户空间(应用程序的活动空间)和内核。
- 内核:本质上可以理解为一种软件,控制计算机的硬件资源,并提供上层应用程序运行的环境。
- 用户空间:上层应用程序活动的空间。应用程序的执行必须依托于内核提供的资源,包括CPU资源、存
储资源、I/O资源等。 - 系统调用:为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用。
所有进程初始都运行于用户空间,此时即为用户运行状态(简称:用户态);但是当它调用系统调用执行某些操作时,例如 I/O调用,此时需要陷入内核中运行,我们就称进程处于内核运行态(或简称为内核态)。 系统调用的过程可以简单理解为:
- 用户态程序将一些数据值放在寄存器中, 或者使用参数创建一个堆栈, 以此表明需要操作系统提供的服务。
- 用户态程序执行系统调用。
- CPU切换到内核态,并跳到位于内存指定位置的指令。
- 系统调用处理器(system call handler)会读取程序放入内存的数据参数,并执行程序请求的服务。
- 系统调用完成后,操作系统会重置CPU为用户态并返回系统调用的结果。由此可见用户态切换至内核态需要传递许多变量,同时内核还需要保护好用户态在切换时的一些寄存器值、变量等,以备内核态切换回用户态。这种切换就带来了大量的系统资源消耗,这就是synchronized未优化之前,效率低的原因。
5. Synchronized优化问题
5.1 CAS
CAS:无锁并发操作,比较并交换(Compare And Swap)
CAS操作依赖3个值:内存中的值V,旧的预估值X,要修改的新值B,如果旧的预估值X等于内存中的值V,就将新的值B保存到内存中。
public class Demo01 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger atomicInteger = new AtomicInteger();
Runnable mr = () -> {
for (int i = 0; i < 1000; i++) {
atomicInteger.incrementAndGet();
}
};
ArrayList<Thread> ts = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Thread t = new Thread(mr);
t.start();
ts.add(t);
}
for (Thread t : ts) {
t.join();
}
System.out.println("number = " + atomicInteger.get());
}
}
分析:
AtomicInteger底层 使用的就是UnSafe类的CAS原子操作保证原子性!!!
CAS获取共享变量时,为了保证该变量的可见性,需要使用volatile修饰。结合CAS和volatile可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下。
- 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一。
- 但如果竞争激烈,可以想到一直自旋,重试频繁发生,反而效率会受影响。
5.2 Unsafe类
Unsafe类使Java拥有了像C语言的指针一样操作内存空间的能力,同时也带来了指针的问题。过度的使用Unsafe类会使得出错的几率变大,因此Java官方并不建议使用的,官方文档也几乎没有。Unsafe对象不能直接调用,只能通过反射获得。
5.3 synchronized锁升级过程
高效并发是从JDK 5到JDK 6的一个重要改进,HotSpot虛拟机开发团队在这个版本上花费了大量的精力去实现各种锁优化技术,包括偏向锁( Biased Locking )、轻量级锁( Lightweight Locking )和如适应性自旋(Adaptive Spinning)、锁消除( Lock Elimination)、锁粗化( Lock Coarsening )等,这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。
锁的升级过程:
- 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
5.4 Java对象的布局
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:
其中对象头包括
-
Mark Word(运行时数据):用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、
线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。Mark Word对应的类型是markOop 。源码位于markOop.hpp 中。 -
Klass pointer(类型指针):这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。 如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。
object pointer普通对象指针。开启该选项后,下列指针将压缩至32位:
- 每个Class的属性指针(即静态变量)
- 每个对象的属性指针(即对象变量)
- 普通对象数组的每个元素指针
实例数据指的是类中定义的成员变量
对其填充并不是必然存在的,仅仅起到了占位符的作用
5.5 偏向锁
偏向锁是JDK6中的重要引进,因为HotSpot作者经过研究实践发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。
偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及ThreadID即可。
偏向锁的原理
当线程第一个访问同步代码块,偏向锁的处理流程:
- 虚拟机会将对象头中的标志位设置为01,即表示偏向状态
- 同时使用CAS操作把获取这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,则持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何的同步操作,故偏向锁的效率很高
偏向锁的撤销
- 偏向锁的撤销动作必须等待全局安全点(Safe Point)
- 暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态
- 撤销偏向锁,恢复到无锁(标志位为01)或者轻量级锁(标志位为00)的状态
偏向锁的好处
偏向锁是在只有一个线程执行同步块时进一步提高性能,适用于一个线程反复获得同一锁的情况。偏向锁可以提高带有同步但无竞争的程序性能。
全局安全点(Safe Point)
提到安全点,必然要将垃圾回收(GC),我们的JVM的GC是不是时刻都在发生呢?
- 答案肯定是否定的,只有在STW(Stop the World)的时候才会将用户线程停下来才会执行垃圾回收,那么问题来了,用户线程那么多,需要如何治理呢?这便需要我们的的安全点和安全区域。
什么是安全点 Safe Point
程序在执行时,并非是在所有地方都能停顿下来开始进行GC,只有在特定的位置才能停顿下来进行GC,而这些位置就被称为安全点
安全的选择
安全点的选择时十分重要的,如果太少就会导致GC等待的时间太长,如果太多,则会导致GC太频繁,从而影响用户线程的运行性能。一般来说,大多数的指令都比较的短暂,故我们选取安全点通常选取的是是否具有让程序长时间执行的标准,比如选择一些执行时间较长的指令来作为Safe Point,例如:
- 循环的末尾
- 方法返回前
- 调用方法后
- 抛出异常的位置
GC时线程的中断策略 - 如何在发生GC时,检查所有的线程都跑到最近的安全点然后停顿下来呢?
一般是采取两种方式:
- 抢先式中断,就是首先中断所有的线程,然后检查这些线程是否都处于安全点,如果不在安全点,则恢复该线程,让该线程跑到最近的安全点,但是这种方式目前已经不采用了
- 主动式中断,即设置一个中断标志位,各个线程运行到Safe Point以后,主动的去轮询这个标志位:
- 如果此时中断标志位为真,则将自己的线程进行中断挂起状态
- 不为真,则继续运行
安全区域(Safe Region)
我们说SafePoint保证了程序执行的时候,在不太长的时间内就会遇到可以进入的GC的SafePoint,但是,如果此时线程处于不执行状态呢?例如线程处于Sleep或者是Blocked状态,此时线程无法响应JVM的中断请求,走到了安全点再去中断挂起。JVM也不太可能等待线程被唤醒。对于这种情况来说,就需要我们的安全区域(Safe Region)来进行解决。
安全区域指的是一段代码块中,对象的引用关系不会发生改变,在这个区域中的任何位置开始GC都是安全的,我们可以将其称为扩展的SafePoint
安全区域的的执行流程
- 当线程运行到Safe Region的代码的时候,首先会标识已经进入了Safe Region,如果此时发生了GC,则JVM会忽视标识为Safe Region状态的线程(即认为他们是安全的)
- 当线程即将离开的时候,会检查此时JVM是否已经完成了GC,如果已经完成,则继续运行;否则线程就会必须等待收到可以安全离开Safe Region的信号为止,即GC结束以后才能离开
简单理解:
- 一句话,睡觉可以,请先进入酒店再睡觉,并且进去是在屏幕上说一声我进入安全区域了,在睡觉。这样做的目的就是当要GC的时候不至于找不到你,如果看到屏幕上有你这个线程的名字,就知道你是安全的,就会忽略你;
- 你(线程)睡醒了要出门了。抬头看看大屏幕是不是安全在出去(true),如果不安全(false)就在酒店待着,别出门,等到gc完成后,状态变为false在出门。
参考博客链接:https://yuhongliang.blog.csdn.net/article/details/109235471?utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7EOPENSEARCH%7Edefault-6.essearch_pc_relevant&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7EOPENSEARCH%7Edefault-6.essearch_pc_relevant
5.6 轻量级锁
什么是轻量级锁
轻量级锁是JDK 6之中加入的新型锁机制,它名字中的“轻量级”是相对于使用monitor的传统锁而言的,因此传统的锁机制就称为“重量级”锁。首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的。
引入轻量级锁的目的:在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。
轻量级锁的原理
当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下: 获取锁
- 判断当前对象是否处于无锁状态(hashcode、0、01),如果是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),将对象的Mark Word复制到栈帧中的Lock Record中,将Lock Reocrd中的owner指向当前对象。
- JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00,执行同步操作。
- 如果失败则判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态。
轻量级锁的释放
轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:
- 取出在获取轻量级锁保存在Displaced Mark Word中的数据。
- 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功。
- 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要将轻量级锁需要膨胀升级为重量级锁。
对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。
轻量级锁好处
在多线程交替执行同步块的情况下,可以避免重量级锁引起的性能消耗。
5.7 自旋锁
自旋锁的原理
自旋锁在JDK 1.4.2中就已经引入 ,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK 6中 就已经改为默认开启了。自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长。那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,用户可以使用参数-XX : PreBlockSpin来更改。
适应性自旋锁
在JDK 6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100次循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虛拟机就会变得越来越“聪明”了。
5.8 锁消除
锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
例如:StringBuffer本身是线程安全的,内部的方法使用synchronized关键字修饰,如果此时我们的StringBuffer只是作为一个方法的局部变量,且并没有逃逸出该方法的作用范围,此时我们的synchronized加锁操作就可以锁消除了
5.9 锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
例如:我们对于一个循环体中的同一个对象,进行每一次操作的加锁,就会导致不必要的性能损耗,此时不妨将锁的颗粒度变大一些,直接锁粗化到循环体外部
6. synchronized使用时的优化
- 减少synchronized锁的范围
- 降低synchronized的锁的颗粒度(例如ConcurrentHashMap中的分段锁)
- 读写分离:例如ReentrantReadWriteLock实现读锁和写锁,其中读锁是共享的,而写锁是独占的
持**,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
例如:StringBuffer本身是线程安全的,内部的方法使用synchronized关键字修饰,如果此时我们的StringBuffer只是作为一个方法的局部变量,且并没有逃逸出该方法的作用范围,此时我们的synchronized加锁操作就可以锁消除了
5.9 锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
例如:我们对于一个循环体中的同一个对象,进行每一次操作的加锁,就会导致不必要的性能损耗,此时不妨将锁的颗粒度变大一些,直接锁粗化到循环体外部
6. synchronized使用时的优化
- 减少synchronized锁的范围
- 降低synchronized的锁的颗粒度(例如ConcurrentHashMap中的分段锁)
- 读写分离:例如ReentrantReadWriteLock实现读锁和写锁,其中读锁是共享的,而写锁是独占的