关于线程安全一提到可能就是加锁,在面试中也是面试官百问不厌的考察点,往往能看出面试者的基本功和是否对线程安全有自己的思考。
那锁本身是怎么去实现的呢?又有哪些加锁的方式呢?
我今天就简单聊一下乐观锁和悲观锁,他们对应的实现 CAS ,Synchronized,ReentrantLock
乐观锁:反之,总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
悲观锁:顾名思义,就是比较悲观的锁,总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。
compare and swap(比较和交换)。是乐观锁的一种实现方式,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。
在使用CAS之前,比如我们在多线程的情况下进行对一个数据的操作,最经典的就是for循环中的++操作,为了保证线程安全我们就必须进行加锁。
举个栗子:现在一个线程要修改数据库的name,修改前我会先去数据库查name的值,发现name=“帅铭”,拿到值了,我们准备修改成name=“三歪”,在修改之前我们判断一下,原来的name是不是等于“帅铭”,如果被其他线程修改就会发现name不等于“帅铭”,我们就不进行操作,如果原来的值还是帅丙,我们就把name修改为“三歪”,至此,一个流程就结束了。
但是就为了一个++加锁,未免大材小用,因此使用CAS更加便捷一点,CAS的逻辑流程如图下:
CAS在操作某个数据之前,会先读取,然后进行计算,计算完成之后会使用读取值和原数据进行比较,如果没变,说明没有线程动原值,就将计算结果赋给它,如果变了,那么就放弃计算结果,再次读取这个数据,计算,比较,直到计算后数值没有变,在进行运算接过赋值。
在这个过程中,我们是不需要锁的。
但是,这个过程中会出现一个ABA问题,什么是ABA问题呢?
就是说,加入我们读取到了这个数据,在对它进行计算后再去将读取值和当前数据值进行对比,虽然十一样的,但是这个值又可能是被别人修改操作完成又再一次复原的结果,那我们怎么感知它呢?
这个处理过程会给数据加一个版本号,每次改动值的时候进行版本号改动,读取的时候顺道将版本号也读取,进行比较,在进行操作计算。
实际上,我们有很多方法去处理ABA问题,例如搞个自增的字段,操作一次就自增加一,或者搞个时间戳,比较时间戳的值。
悲观锁从宏观的角度讲就是,他是个渣男,你认为他每次都会渣你,所以你每次都提防着他。
synchronized,代表这个方法加锁,相当于不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。
synchronized是锁这个大家族中极为有头有脸的一位人物,所以我们有必要重点讲解一下这位人才。
把代码块声明为 synchronized,有两个重要后果,通常是指该代码具有 原子性(atomicity)和 可见性(visibility)。所以我们需要同步,那么到底什么时候需要同步?
可见性同步的基本规则是在以下情况中必须同步:
读取上一次可能是由另一个线程写入的变量
写入下一次可能由另一个线程读取的变量
一致性同步:当修改多个相关值时,您想要其它线程原子地看到这组更改—— 要么看到全部更改,要么什么也看不到。
这适用于相关数据项(如粒子的位置和速率)和元数据项(如链表中包含的数据值和列表自身中的数据项的链)。
在某些情况中,您不必用同步来将数据从一个线程传递到另一个,因为 JVM 已经隐含地为您执行同步。这些情况包括:
由静态初始化器(在静态字段上或 static{} 块中的初始化器)
初始化数据时
访问 final 字段时 ——final对象呢?
在创建线程之前创建对象时
线程可以看见它将要处理的对象时
经典方式:
Object o = new Object();
synchronized(o){
System.out.println("XXX");
}
说起synchronized的实现原理,我们就必须提到对象在内存中的组成部分。、
实际上,对象在内存中主要由四个部分组成:
1、对象头(markword):8个字节
2、类型指针(class pointer):(java默认压缩的时候)4个字节
3、实例数据(instance data):待定
4、补齐(padding):使整个对象大小为8字节的倍数。
而synchronized的信息,都放在对象头中。
我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 你可以看到在对象头中保存了锁标志位和指向 monitor 对象的起始地址,如下图所示,右侧就是对象对应的 Monitor 对象。
当 Monitor 被某个线程持有后,就会处于锁定状态,如图中的 Owner 部分,会指向持有 Monitor 对象的线程。
另外 Monitor 中还有两个队列分别是EntryList和WaitList,主要是用来存放进入及等待获取锁的线程。
如果线程进入,则得到当前对象锁,那么别的线程在该类所有对象上的任何操作都不能进行。
synchronized 应用在方法上时,在字节码中是通过方法的 ACC_SYNCHRONIZED 标志来实现的。其他线程进这个方法就看看是否有这个标志位,有就代表有别的仔拥有了他,你就别碰了。
synchronized 应用在同步块上时,在字节码中是通过 monitorenter 和 monitorexit 实现的。
每个对象都会与一个monitor相关联,当某个monitor被拥有之后就会被锁住,当线程执行到monitorenter指令时,就会去尝试获得对应的monitor。
步骤如下:
每个monitor维护着一个记录着拥有次数的计数器。未被拥有的monitor的该计数器为0,当一个线程获得monitor(执行monitorenter)后,该计数器自增变为 1 。
当同一个线程再次获得该monitor的时候,计数器再次自增;
当不同线程想要获得该monitor的时候,就会被阻塞。
当同一个线程释放 monitor(执行monitorexit指令)的时候,计数器再自减。
当计数器为0的时候,monitor将被释放,其他线程便可以获得monitor。
在多线程并发编程中 synchronized 一直是元老级角色,很多人都会称呼它为重量级锁。
但是,随着 Java SE 1.6 对 synchronized 进行了各种优化之后,有些情况下它就并不那么重,Java SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。
针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。
对象刚刚创建出来,使用无锁状态,JVM会默认加上偏向锁,有人征用这个对象会自动升级成轻量级锁,在竞争很大线程很多的情况下,会升级成重量级锁。
这些信息全部记录在对象头中,8字节64位:
1、new状态:无锁状态
2、 偏向锁 :当前线程指针(标签)标记,当前线程专用。
3、轻量级锁(自旋锁/无锁) :指向线程中Lock Record指针,采用CAS,看看谁能将自己的Lock Record对象指针指向锁,但是消耗CPU。
4、 重量级锁:指向互斥级别指针。
注意:锁只能升级,不能降级。
synchronized是不错,但它并不完美。它有一些功能性的限制:
它无法中断一个正在等候获得锁的线程;
也无法通过投票得到锁,如果不想等下去,也就没法得到锁;
同步还要求锁的释放只能在与获得锁所在的堆栈帧相同的堆栈帧中进行,多数情况下,这没问题(而且与异常处理交互得很好),但是,确实存在一些非块结构的锁定更合适的情况。
在介绍完同步的经典方法synchronized之后,那么有没有其他方法在进行同步呢?当然有——ReentrantLock,但是在介绍这玩意之前,我觉得我有必要先介绍AQS——AbstractQueuedSynchronizer
而在了解AQS是什么之前,首先我们又不得不来普及一下juc是什么:juc其实就是包的缩写(java.util.concurrnt)
不要被人家唬到了,以为juc是什么一个牛逼的东西。其实指的是包而已~
而java.util.concurrnt包下面的Lock包下面,有这样三个类,
通常的,这三个类中的AbstractQueuedSynchronizer简称为AQS,而AQS到底是什么,看一个类是干什么的最快途径就是看它的顶部注释,太长请自行阅读。
我们深入到AQS这个类中,就可以得知以下信息:
AQS 有一个 state 标记位,并且使用volatile关键字实现可见性,值为1 时表示有线程占用,其他线程需要进入到同步队列等待,同步队列是一个双向链表。而修改state状态值时使用CAS算法来实现:
同步队列:这个队列被称为:CLH队列(三个名字组成),是一个双向队列
当获得锁的线程需要等待某个条件时,会进入 condition 的等待队列,等待队列可以有多个。
当 condition 条件满足时,线程会从等待队列重新进入同步队列进行获取锁的竞争。
这就是AQS的一些原理和实现方式,而我们Lock之类的两个常见的锁都是基于它来实现的:
如下图所示,ReentrantLock 内部有公平锁和非公平锁两种实现,差别就在于新来的线程是否比已经在同步队列中的等待线程更早获得锁。
java.util.concurrent.lock
中的Lock 框架是锁定的一个抽象,它允许把锁定的实现作为 Java 类,而不是作为语言的特性来实现。这就为Lock 的多种实现留下了空间,各种实现可能有不同的调度算法、性能特性或者锁定语义。
ReentrantLock里面有一个内部类Sync,Sync继承AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync中实现的。
Sync有公平锁FairSync和非公平锁NonfairSync两个子类。
ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
ReentrantLock 类实现了Lock ,它拥有与synchronized 相同的并发性和内存语义,但是添加了类似锁投票、定时锁等候和可中断锁等候的一些特性。此外,它还提供了在激烈争用情况下更佳的性能。(换句话说,当许多线程都想访问共享资源时,JVM 可以花更少的时候来调度线程,把更多时间用在执行线程上。)
class Outputter1 {
private Lock lock = new ReentrantLock();// 锁对象
public void output(String name) {
lock.lock(); // 得到锁
try {
for(int i = 0; i < name.length(); i++) {
System.out.print(name.charAt(i));
}
} finally {
lock.unlock();// 释放锁
}
}
}
区别:
需要注意的是,用sychronized修饰的方法或者语句块在代码执行完之后锁自动释放,而是用Lock需要我们手动释放锁,所以为了保证锁最终被释放(发生异常情况),要把互斥区放在try内,释放锁放在finally内!!
上例中展示的是和synchronized相同的功能,那Lock的优势在哪里?
例如一个类对其内部共享数据data提供了get()和set()方法,如果用synchronized,则代码如下:
class syncData {
private int data;// 共享数据
public synchronized void set(int data) {
System.out.println(Thread.currentThread().getName() + "准备写入数据");
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.data = data;
System.out.println(Thread.currentThread().getName() + "写入" + this.data);
}
public synchronized void get() {
System.out.println(Thread.currentThread().getName() + "准备读取数据");
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "读取" + this.data);
}
}
然后写个测试类来用多个线程分别读写这个共享数据:
public static void main(String[] args) {
// final Data data = new Data();
final syncData data = new syncData();
// final RwLockData data = new RwLockData();
//写入
for (int i = 0; i < 3; i++) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 5; j++) {
data.set(new Random().nextInt(30));
}
}
});
t.setName("Thread-W" + i);
t.start();
}
//读取
for (int i = 0; i < 3; i++) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 5; j++) {
data.get();
}
}
});
t.setName("Thread-R" + i);
t.start();
}
}
现在一切都看起来很好!各个线程互不干扰!等等。。读取线程和写入线程互不干扰是正常的,但是两个读取线程是否需要互不干扰??
对!读取线程不应该互斥!
我们可以用读写锁ReadWriteLock实现:
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
class Data {
private int data;// 共享数据
private ReadWriteLock rwl = new ReentrantReadWriteLock();
public void set(int data) {
rwl.writeLock().lock();// 取到写锁
try {
System.out.println(Thread.currentThread().getName() + "准备写入数据");
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.data = data;
System.out.println(Thread.currentThread().getName() + "写入" + this.data);
}finally {
rw.writeLock().unlock();// 释放写锁
}
}
public void get() {
rwl.readLock().lock();// 取到读锁
try {
System.out.println(Thread.currentThread().getName() + "准备读取数据");
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "读取" + this.data);
} finally {
rwl.readLock().unlock();// 释放读锁
}
}
}
与互斥锁定相比,读-写锁定允许对共享数据进行更高级别的并发访问。虽然一次只有一个线程(writer 线程)可以修改共享数据,但在许多情况下,任何数量的线程可以同时读取共享数据(reader 线程)
从理论上讲,与互斥锁定相比,使用读-写锁定所允许的并发性增强将带来更大的性能提高。
在实践中,只有在多处理器上并且只在访问模式适用于共享数据时,才能完全实现并发性增强。——例如,某个最初用数据填充并且之后不经常对其进行修改的 collection,因为经常对其进行搜索(比如搜索某种目录),所以这样的 collection 是使用读-写锁定的理想候选者。
这里引申出一个线程间的通信方式——Condition。Condition可以替代传统的线程间通信,用await()替换wait(),用signal()替换notify(),用signalAll()替换notifyAll()。
——为什么方法名不直接叫wait()/notify()/nofityAll()?因为Object的这几个方法是final的,不可重写!
传统线程的通信方式,Condition都可以实现。
注意,Condition是被绑定到Lock上的,要创建一个Lock的Condition必须用newCondition()方法。
Condition的强大之处在于它可以为多个线程间建立不同的Condition 看JDK文档中的一个例子:假定有一个绑定的缓冲区,它支持 put 和 take 方法。如果试图在空的缓冲区上执行 take 操作,则在某一个项变得可用之前,线程将一直阻塞;如果试图在满的缓冲区上执行 put 操作,则在有空间变得可用之前,线程将一直阻塞。我们喜欢在单独的等待 set 中保存put 线程和take 线程,这样就可以在缓冲区中的项或空间变得可用时利用最佳规划,一次只通知一个线程。可以使用两个Condition 实例来做到这一点。 ——其实就是java.util.concurrent.ArrayBlockingQueue的功能
class BoundedBuffer {
final Lock lock = new ReentrantLock(); //锁对象
final Condition notFull = lock.newCondition(); //写线程锁
final Condition notEmpty = lock.newCondition(); //读线程锁
final Object[] items = new Object[100];//缓存队列
int putptr; //写索引
int takeptr; //读索引
int count; //队列中数据数目
//写
public void put(Object x) throws InterruptedException {
lock.lock(); //锁定
try {
// 如果队列满,则阻塞<写线程>
while (count == items.length) {
notFull.await();
}
// 写入队列,并更新写索引
items[putptr] = x;
if (++putptr == items.length) putptr = 0;
++count;
// 唤醒<读线程>
notEmpty.signal();
} finally {
lock.unlock();//解除锁定
}
}
//读
public Object take() throws InterruptedException {
lock.lock(); //锁定
try {
// 如果队列空,则阻塞<读线程>
while (count == 0) {
notEmpty.await();
}
//读取队列,并更新读索引
Object x = items[takeptr];
if (++takeptr == items.length) takeptr = 0;
--count;
// 唤醒<写线程>
notFull.signal();
return x;
} finally {
lock.unlock();//解除锁定
}
}
}
优点:
假设缓存队列中已经存满,那么阻塞的肯定是写线程,唤醒的肯定是读线程,相反,阻塞的肯定是读线程,唤醒的肯定是写线程。
那么假设只有一个Condition会有什么效果呢?缓存队列中已经存满,这个Lock不知道唤醒的是读线程还是写线程了,如果唤醒的是读线程,皆大欢喜,如果唤醒的是写线程,那么线程刚被唤醒,又被阻塞了,这时又去唤醒,这样就浪费了很多时间。
总结一下AQS到底是什么吧:
juc包中很多可阻塞的类都是基于AQS构建的
AQS可以说是一个给予实现同步锁、同步器的一个框架,很多实现类都在它的的基础上构建的
在AQS中实现了对等待队列的默认实现,子类只要重写部分的代码即可实现(大量用到了模板代码)