Java之synchronized详解
前言
本文将对常用的synchronized围绕常见的一些问题进行展开。以下为我们将围绕的问题:
- 乐观锁和悲观锁?
- synchronized的底层是怎么实现的?
- synchronized可重入是怎么实现的?
- synchronized锁升级?
- synchronized是公平锁还是非公平锁?
- synchronized和volatile对比有什么区别?
- synchronized在使用时有何需要注意事项?
注意:下面都是在JDK1.8中进行的。
乐观锁和悲观锁?
关于乐观锁和悲观锁的定义和使用场景,可以看《Mysql InnoDB之各类锁》中,本质都是一样的,这里就不再赘述。
关于悲观锁,下面再进行介绍,synchronized和Lock都属于悲观锁,下面我们来具体看看乐观锁。
乐观锁的实现-CAS
乐观锁的核心就是CAS(Compare And Swap-比较与交换,是一种不抢占的同步方式),是一种无锁算法。CAS算法涉及三个操作数:
- 需要读写的内存值V。
- 进行比较的值A。
- 要写入的新值B。
当前仅当当前内存值V等于值A时,才进行写入新值B,有人会问我在比较相等后的同时更新了值V咋办?写入的新值B不是覆盖了别人刚写入的值吗?是的比较和写入需要保证是一个原子操作,这里通过CPU的cmpxchg指令,去比较寄存器中的A和内存中的值V,如果相等的话就写入B,如果不等的话就值V赋值给寄存器中的值A,如果想继续自旋就继续不想继续可以抛出相应错误。
下面我们来看看常见的AtomicInteger是如何自旋的。
AtomicInteger
一个可以被原子更新的int值,关于原子变量属性描述具体可以参考{@link java.util.concurrent.automic}包。AutomicInteger用于原子递增计数器等应用程序中,不能被使用替代Integer。然而这个类扩展了Number允许被一些处理数值的工具或者公共代码统一访问。
字段和构造函数
package java.util.concurrent.atomic;
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
// 设置使用Unsafe.compareAndSwapInt进行更新。
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
// 获得value对象内存分布中的偏移量用于找到value
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
// 保证内存可见效和禁止指令重排。
private volatile int value;
public AtomicInteger(int initialValue) {
value = initialValue;
}
/**
* 初始值为0
*/
public AtomicInteger() {
}
incrementAndGet
/**
* 以原子方式将当前值递增1
* @return 更新后的值
*/
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
复制代码
var1:为AtomicInteger对象,用于unsafe结合valueOffset获得对象中的最新的value。
var2:value值在AtomicInteger对象中偏移量。
var4:增加的值为1。
package sun.misc;
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
//获得AutomicInteger的value值
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
乐观锁的缺点
- 如果并发比较高,CAS一直比较自旋,将会一直占用CPU,如果自旋的线程多了CPU就会飙升。
- 只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能保证原子操作,但是对多个共享变量操作时,CAS时无法保证操作的原子性的。
- java从1.5开始JDK提供了AtomicReference类来保证引用之间的原子性,可以把多个变量放在一个对象中来进行CAS操作。
synchronized的底层是怎么实现的?
sychronized是通过对象头部的Mark Word中的锁标识+monitor实现的。
java对象头
对象头由Mark Word和Klass组成,在没有压缩指针的时候都占8个字节。
- Mark Word:标记字段-运行时数据,如哈希码、GC信息以及锁信息。
- Klass:对象锁代表的类的元数据指针。
锁标志位+是否是偏向锁(biased_lock)共同表示对象的几种状态
monitor
synchronized通过Monitor来实现线程同步和协作。
- 同步依赖的是操作系统的Mutex(互斥锁量)只有拥有互斥量的线程才能进入临界区,不拥有的只能阻塞等待,会维护一个阻塞的队列。
- 协作依赖的是synchronized持有的对象,对象可以让多个线程方便同步,还可以通过对象调用wait方法释放锁让线程进入等待队列,等其他线程调用对象的notify和notifyAll方法进行唤醒可以重新获取锁。
Monitor用来进行监听锁的持有和调度锁的持有的。持有的对象可以理解为锁的一个媒介,可以使用它方便操作同步和协作。
具体例子可以参考《Thread源码阅读及相关问题》中的例子。
monitor这套监听锁和调度锁包括使用的互斥量其实都是比较消耗资源的,所以使用它的成为“重量级锁”。JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,下面我们分别会进行分配介绍。
无锁
当对象头中锁标志位为01,是否偏向锁为0时表示使无锁的状态。想要在无锁的时候实现同步可以使用上面乐观锁中实现-CAS。
偏向锁
偏向锁是一个锁优化的产物,在对象头中进行标记,表示有线程进入了临界区,在只有一个线程访问的时候既不用使用CAS也不用引入较重的monitor。
线程不会主动释放偏向锁,只有遇到其他线程进尝试竞争偏向锁时,需要等待全局安全点(在这个时间点上没有执行的字节码),它会首先暂停拥有偏向锁的的线程,判断它是否还活着,如果死亡了就恢复到无锁状态其他线程就可以占用,如果还在临界区就对锁进行升级成"轻量锁"。
每个线程进入临界区的时候都会查看对象头锁标识是否是偏向锁,是偏向锁的话,会判断当前线程和对象头中的线程id是同一个线程则直接进入,如果不是则进行CAS看是不是能比较替换成功(防止马上就释放了),如果没成功就会暂停持有偏向锁的线程,看线程是否已经不再用锁了,如果没用就释放,给新进来的线程占用,如果在用就进行锁升级生成轻量锁"。
可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序会默认进入轻量级锁状态。 笔者思考
可以发现在只有一个线程进入临界区的时候确实能避免使用互斥量带来的开销,但是可以发现线程不会主动释放偏向锁。为啥不当有偏向锁的时候离开临界区进行释放?还要等其他线程来的时候要等全局点的时候尝试对线程暂停之后再看该线程持有锁的状态?这些疑问我们考虑不全,可能是设计的问题,也可能是因为一些其他原因,我们对JVM源码不够熟悉的情况下会比较费解。或许后面迭代会进行优化。
所以我们只需要明白一点:偏量锁是一种锁的优化,它本质上不是锁,只是对象头中进行了标记,如果没有多线程并发访问临界区的时候可以减少开销,如果出现多并发的时候会进行升级。
轻量级锁
轻量锁发生在偏向锁升级或者-XX:-UseBiasedLocking=false
的时候,线程在执行同步块之前,JVM会现在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaces Mark Word。然后线程尝试使用CAS将对头像中的Mark Word替换为之指向锁记录的指针。如果成功,当前线程获得锁,如果失败将通过自旋来进行同步。
重量级锁
重量锁的锁标志位为10,就是上面介绍的monitor机制,开销最大。
synchronized锁升级?
如上面的synchronized的底层实现章节。
synchronized可重入是怎么实现的?
我们先用代码证明下:
public class SynchronizedReentrantTest extends Father {
public synchronized void doSomeThing1() {
System.out.println("doSomeThing1");
doSomeThing2();
}
public synchronized void doSomeThing2() {
System.out.println("doSomeThing2");
super.fatherDoSomeThing();
}
public static void main(String[] args) {
SynchronizedReentrantTest synchronizedReentrantTest = new SynchronizedReentrantTest();
synchronizedReentrantTest.doSomeThing1();
}
}
class Father {
public synchronized void fatherDoSomeThing() {
System.out.println("fatherDoSomeThing");
}
}
复制代码
输出:
doSomeThing1
doSomeThing2
fatherDoSomeThing
说明synchronized是可重入的。
重量级锁使用的是monitor对象中的计数字段来实现的,偏向锁应该没有只有表示当前被那个线程持有,轻量锁在每次进入的时候都会添加一个Lock Record来表示锁的重入次数。
笔者思考
为啥偏向锁不记录重入次数,重入的时候只需要看是否是当前线程,对象头中没有地方存放次数,所以偏向锁不会主动释放(应该是判断嵌套临界区比较麻烦),需要另外一个线程来判断当前线程是否活跃死亡了才释放还会尝试暂停持有的线程。这点其实不如轻量级锁和重量级锁。
synchronized是公平锁还是非公平锁?
非公平的,直接下面打饭的例子:
import lombok.SneakyThrows;
public class SyncUnFairLockTest {
//食堂
private static class DiningRoom {
//获取食物
@SneakyThrows
public void getFood() {
System.out.println(Thread.currentThread().getName() + ":排队中");
synchronized (this) {
System.out.println(Thread.currentThread().getName() + ":@@@@@@打饭中@@@@@@@");
Thread.sleep(200);
}
}
}
public static void main(String[] args) {
DiningRoom diningRoom = new DiningRoom();
//让5个同学去打饭
for (int i = 0; i < 5; i++) {
new Thread(() -> {
diningRoom.getFood();
}, "同学编号:00" + (i + 1)).start();
}
}
}
输出:
同学编号:001:排队中
同学编号:001:@@@@@@打饭中@@@@@@@
同学编号:005:排队中
同学编号:003:排队中
同学编号:004:排队中
同学编号:002:排队中
同学编号:002:@@@@@@打饭中@@@@@@@
同学编号:004:@@@@@@打饭中@@@@@@@
同学编号:003:@@@@@@打饭中@@@@@@@
同学编号:005:@@@@@@打饭中@@@@@@@
注意到这里我加了sleep,因为对于公平锁来说无所谓,先来的肯定先执行,但是非公平锁时后面来的线程会先进行尝试获得锁,获取不到再进入队列,这样就能避免同一进入队列再被CPU唤醒,能提高效率,但是非公平锁会出现饿死的情况。
synchronized和volatile对比有什么区别?
都能保证可见效,synchronized因为是锁所以能保证原子性。
可见效主要指的是线程共享时工作内存和主内存能否及时同步。
JMM关于synchronized的两个规定:
- 线程解锁前,必须把共享变量的最新值刷新到主内存中。
- 线程加锁时,将清空工作内存中共享变量的值,从而使变量共享时,需要从主内存中重新读取最新的值。