JAVA锁机制详解
JAVA锁机制详解
- JAVA锁机制详解
- 公平锁和非公平锁
- 可重入锁(递归锁)
- 自旋锁
- 独占锁(写锁)/共享锁(读锁)/互斥锁
公平锁和非公平锁
公平锁:是指多个线程按照申请锁的顺序来获取锁,类似排队买饭,先来后到,先进先出。
非公平锁:是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程有限获取锁,在高并发的情况下,有可能造成优先级反转或者饥饿现象。
ReentrantLock是什么公平锁还是非公平锁呢?我们创建ReentrantLock对象一般的写法是:
Lock lock = new ReentrantLock();
我们可以看一下源码是怎么写的。
/**
*
* 创建ReentrantLock一个实例。 这相当于使用ReentrantLock(false) ,也就是说在无参构造函数中是非公平锁
*/
public ReentrantLock() {
sync = new NonfairSync();
}
/**
*
* 使用给定的公平策略创建ReentrantLock的实例。
*
* 参数:
* fair: 如果为TRUE,则为公平锁,否则为非公平锁
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
由上边的两个构造方法中可以看出:ReentrantLock(boolean fair)可以通过fair来设置公平锁或者非公平锁, new ReentrantLock() 和new ReentrantLock(false)等价,都是非公平锁,new ReentrantLock(true)为公平锁。
synchronized也属于非公平锁。
公平锁:就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后会按照FIFO(先入先出)的规则从队列中取到自己。
非公平锁:比较粗鲁,上来就直接尝试占有锁,如果尝试失败,就再采用类似公平锁那种方式。性能更好。
可重入锁(递归锁)
ReentrantLock翻译过来就是重入锁(synchronized也是可重入锁)。可重入锁也叫做递归锁。指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取该锁的代码,在同一个线程外层方法获取锁的时候,在进入内层方法会自动获取锁。也就是说,线程可以进入任何一个已经拥有的锁同步着的代码块。可重入锁可防止死锁。
/**
m1和m2都是同步方法,m1调用m2,当线程获取到了m1的锁,由于在m1的代码块中调用了同步方法m2,所以线程会自动获取m2的锁
这样的锁就是可重入锁
*/
public synchronized void m1() {
m2();
}
public synchronized void m2() {
}
生活的例子:以我们最常用的智能手机为例,现在的手机都有锁屏的功能,我们只有解锁了屏幕才可以听音乐,玩游戏……也就是说我们只有将手机屏幕解锁了,才可以访问手机里所有的应用程序和资源,不解锁就什么都访问不了。下边用一段代码来模仿这个小例子。
synchronized为可重入锁例子
public class LockTest {
public static void main(String[] args) {
Phone phone = new Phone();
new Thread(()-> {
phone.screen();
},"T1").start();
new Thread(()-> {
phone.screen();
},"T2").start();
}
}
class Phone {
// 解锁屏幕
public synchronized void screen() {
System.out.println(Thread.currentThread().getId() + "解锁了屏幕!");
music();
game();
}
// 听音乐
public synchronized void music() {
System.out.println(Thread.currentThread().getId() + "听音乐!");
}
// 玩游戏
public synchronized void game() {
System.out.println(Thread.currentThread().getId() + "玩游戏!");
}
}
输出结果:
10解锁了屏幕!
10听音乐!
10玩游戏!
11解锁了屏幕!
11听音乐!
11玩游戏!
ReentrantLock为可重入锁例子
public class LockTest {
public static void main(String[] args) {
IPhone phone = new IPhone();
new Thread(()-> {
phone.screen();
},"T1").start();
new Thread(()-> {
phone.screen();
},"T2").start();
}
}
class IPhone {
Lock lock = new ReentrantLock();
// 解锁屏幕
public void screen() {
lock.lock();
try {
System.out.println(Thread.currentThread().getId() + "解锁了屏幕!");
music();
game();
} finally {
lock.unlock();
}
}
// 听音乐
public void music() {
lock.lock();
try {
System.out.println(Thread.currentThread().getId() + "听音乐!");
} finally {
lock.unlock();
}
}
// 玩游戏
public void game() {
lock.lock();
try {
System.out.println(Thread.currentThread().getId() + "玩游戏!");
} finally {
lock.unlock();
}
}
}
运行结果:
10解锁了屏幕!
10听音乐!
10玩游戏!
11解锁了屏幕!
11听音乐!
11玩游戏!
结论:
- 上边的两个例子的输出结果一致,也证明了同一个线程在解锁手机屏幕之后,此线程可以听这个手机的音乐,也可以玩这个手机的游戏……即访问这个手机中的资源。虽然听音乐和玩游戏都被加锁,但是都在解锁屏幕中的代码块里边,线程可以自动获取音乐和游戏的锁。线程可以进入任何一个已经拥有的锁同步着的代码块。
- synchronized和ReentrantLock都是可重入锁
自旋锁
自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式取尝试获取新锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU.
为了便于理解,举一个现实的例子。假如在开发过程中,有一个需求不是很明白,需要和产品经理确认,但是很不巧的是此时产品经理正在开会,那你就先去继续开发别的任务,过了10分钟,你又去找产品经理,还是没有开完会,你继续开发别的任务……如此循环,直到产品经理开完会处理你的问题。而你就是一直在循环尝试获取产品经理的时间来确认需求,但是你的工作并没有阻塞,你可以继续做你自己其他的工作,等到产品经理开完会有了时间你也就获取了产品经理“这把锁”。
我们再来看一下在CAS中的UnSafe类中的一段自旋的代码:
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
// 根据this和内存偏移量获取对象,相当于将主存中的变量拷贝到线程自己的工作内存。表示拿变量的最新值。
var5 = this.getIntVolatile(var1, var2);
// this.compareAndSwapInt(var1, var2, var5, var5 + var4)
// 进行比较交换:var1,var2用于确定初始值,var5表示期望值,初始值和期望值进行比较,相等
// 则进行加操作(var5 + var4)得到更新值,并返回true,不相等返回false。
// while的循环条件中取反表示如果初始值和期望值相等(!true),可以进行更新,并跳出循环,
// 如果初始值和期望值不相等(!false),不可以进行更新,一直循环直到相等。
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
场景:T1和T2两个线程,首先先让T1尝试获取锁,假如T1获取锁之后,T2在一秒后尝试获取同一把锁,T1在获取锁之后5秒释放锁。由于T1先获取锁,T1在持有锁的5秒钟T2只能等待T1释放锁。
下边我们来用代码来描绘一下上边的场景。
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
public class SpinLockTest {
AtomicReference<Thread> threadAtomicReference = new AtomicReference<>();
public static void main(String[] args) {
SpinLockTest spinLock = new SpinLockTest();
new Thread(() -> {
spinLock.lock();
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
spinLock.unLock();
}, "T1").start();
// 等待1秒,保证T1线程先获取到锁
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
spinLock.lock();
spinLock.unLock();
}, "T2").start();
}
// 加锁
public void lock() {
// 获取当前线程
Thread thread = Thread.currentThread();
// threadAtomicReference的泛型是Thread,当threadAtomicReference中的线程对象是null的时候表示没有线程持有锁,让当前
// 的线程线程对象持有锁。
// threadAtomicReference.compareAndSet(null,thread) 表示threadAtomicReference中的线程对象是null,则将当前的
// 线程放进threadAtomicReference,!取反则表示threadAtomicReference中有其他线程(不为null),不允许对threadAtomicReference
// 中的线程对象进行操作。
// 也就是说只要有T1线程占据threadAtomicReference,其他的线程都不能修改threadAtomicReference中的线程对象,达到了锁的效果
// while则表示其他线程一直尝试获取锁,但只要有其他的线程占用,抢占锁的线程就无法获取锁,只能一直循环抢占(自旋)
while (!threadAtomicReference.compareAndSet(null,thread)) {
}
System.out.println(thread.getName() + "加锁");
}
// 解锁
public void unLock() {
// 获取当前线程
Thread thread = Thread.currentThread();
// 只有占据threadAtomicReference的线程才可以打开锁(将占有threadAtomicReference对象的线程设为null)
threadAtomicReference.compareAndSet(thread, null);
System.out.println(thread.getName() + "释放锁");
}
}
运行结果:
T1加锁
T1释放锁
T2加锁
T2释放锁
独占锁(写锁)/共享锁(读锁)/互斥锁
独占锁:指该锁一次只能被一个线程所持有,对synchronized和ReentrantLock而言都是独占锁。
共享锁:指该锁可以被多个线程所持有。
对ReentrantReadWriteLock而言读锁是共享锁,写锁是独占锁。该锁的共享锁可保证并发读是非常高效的,读写,写读,写写的过程是互斥的。
在写操作中,必须要保证原子性,也就是说在写数据的时候不能被打断,要一次性写成功。
我们很多的场景应该是多个线程同时读一个资源类没有任何问题,所以为了满足并发量,读取共享资源应该可以同时进行,多个线程同时但是,如果一个此案城去写共享资源类,就不应该再有其他的线程可以对该资源进行读和写。也就是说:读读操作可以同时进行,读写和写写操作要分开执行,等一个线程写完之后另一个线程才可以进行读或者写,也就是要保证某个写线程的原子性和独占锁。
首先我们先看一下错误的例子:
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public class ReentrantReadWriteLockTest {
public static void main(String[] args) {
myMap cache = new myMap();
// 写数据
for (int i = 1; i <= 5; i++) {
int finalI = i;
new Thread(()->{
cache.put(String.valueOf(finalI),finalI);
}, String.valueOf(i)).start();
}
// 读数据
for (int i = 1; i <= 5; i++) {
int finalI = i;
new Thread(()->{
cache.get(String.valueOf(finalI));
}, String.valueOf(i)).start();
}
}
}
class myMap {
private volatile Map<String, Object> cache = new HashMap<>();
public void put(String key, Object value) {
System.out.println(Thread.currentThread().getName() + "正在写入:" + key);
// 模拟网络不稳定的情况,线程暂停300毫秒
try {
TimeUnit.MICROSECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
cache.put(key,value);
System.out.println(Thread.currentThread().getName() + "写入完成!");
}
public void get(String key) {
System.out.println(Thread.currentThread().getName() + "正在读取");
// 模拟网络不稳定的情况,线程暂停300毫秒
try {
TimeUnit.MICROSECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
Object value = cache.get(key);
System.out.println(Thread.currentThread().getName() + "读取数据:" + value);
}
}
运行结果:
1正在写入:1
3正在写入:3
2正在写入:2
4正在写入:4
5正在写入:5
1正在读取
2正在读取
3正在读取
4写入完成!
1写入完成!
5写入完成!
2写入完成!
3写入完成!
4正在读取
5正在读取
1读取数据:1
2读取数据:2
3读取数据:null
4读取数据:4
5读取数据:5
结果分析:从以上结果可以看出来,线程在写的过程中出现了其他线程的加塞,并非原子性,导致了数据的错误,应该的结果是:1正在写入执行之后不允许其他线程加塞,直到出现1写入完成!其他的线程才可以进行写操作,这样才可以保证数据的准确性。而上边的结果违背了原子性。
我们可以使用ReentrantReadWriteLock进行加锁,那有的同学可能就会有疑问了,使用synchronized或者ReentrantLock不可以吗?别忘了我们的需求是读读操作是可以多线程进行读取的,使用synchronized或者ReentrantLock对于读读操作也是只能有一个线程进行读,不满足我们的需求,更重要的是效率会比较低。
那我们再来看一下改良版的代码:
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReentrantReadWriteLockTest {
public static void main(String[] args) {
myMap cache = new myMap();
// 写数据
for (int i = 1; i <= 5; i++) {
int finalI = i;
new Thread(()->{
cache.put(String.valueOf(finalI),finalI);
}, String.valueOf(i)).start();
}
// 读数据
for (int i = 1; i <= 5; i++) {
int finalI = i;
new Thread(()->{
cache.get(String.valueOf(finalI));
}, String.valueOf(i)).start();
}
}
}
class myMap {
private volatile Map<String, Object> cache = new HashMap<>();
private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public void put(String key, Object value) {
// 使用写锁,保证了线程写数据的原子性
lock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + "正在写入:" + key);
// 模拟网络不稳定的情况,线程暂停300毫秒
try {
TimeUnit.MICROSECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
cache.put(key,value);
System.out.println(Thread.currentThread().getName() + "写入完成!");
} finally {
// 写操作完成,进行解锁
lock.writeLock().unlock();
}
}
public void get(String key) {
// 使用读锁,对于线程的读操作,多个线程可以同时进行
lock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + "正在读取");
// 模拟网络不稳定的情况,线程暂停300毫秒
try {
TimeUnit.MICROSECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
Object value = cache.get(key);
System.out.println(Thread.currentThread().getName() + "读取数据:" + value);
} finally {
// 读锁解锁
lock.readLock().unlock();
}
}
}
运行结果:
1正在写入:1
1写入完成!
2正在写入:2
2写入完成!
3正在写入:3
3写入完成!
4正在写入:4
4写入完成!
5正在写入:5
5写入完成!
1正在读取
1读取数据:1
2正在读取
2读取数据:2
3正在读取
3读取数据:3
4正在读取
5正在读取
4读取数据:4
5读取数据:5
结果分析:可以看到对于写操作都是原子性的,某个线程的正在写入和写入完成中间没有其他线程打断加塞,说明ReentrantReadWriteLock满足我们的需求,以后对于类似的场景可以使用ReentrantReadWriteLock。
今天的文章已经介绍完毕了,下篇会进行介绍乐观锁和悲观锁,偏向锁/轻量级锁/重量级锁,如果觉得我写的好的话请各位点个赞,点个关注,也欢迎各位大佬的评论指正,谢谢!