文章目录
java实践8各种锁的实现和解析(上篇)
促使我写,这篇文章的起源是一个小伙伴,面试被问到java并发包下的ReentrantLock 各种锁、实现原理。网上有很多文章都有各种介绍、源码。但看完之后还是一头雾水,各种方法名很长,背着费劲,没有业务场景,不好理解。ReentrantLock虽然我用的不多,但我在实现锁方面,还是有一些体会(因为其他的一些场景,自己实现过集中方案,用其他的怕控制不好)。下面我会结合场景和代码,给大家分享一下我的理解。
1、什么是锁
锁:本质上其实就是在内存中的一个数,来表示加锁和无锁状态,在多线程环境中,为了保证数据的正常,会用到锁。通过锁的机制,可以保证程序在多线程访问的情况下,数据不会错乱,避免程序出现未知异常。
在我的理解中,java只提供了2种方法,就是synchronized和cas(具有原子性,可调用硬件级别的指令实现的同步原语)来实现锁。我两个我认为才是最重要的。其他的基本都是一些概念,我们要根据不同的业务场景来做不同的优化。java中各种锁、线程安全的类,都是基于他们两个来的。
场景:提供对外API,当请求进入时把请求,处理请求,处理完后累加处理了多少个请求。
//模拟对外API接口
public class DuiwaiApi {
int handlerNums = 0;
//请求进来进行累加
public void add() {
//做一些事…
//累加已处理请求数
handlerNums++;
System.out.println(Thread.currentThread().getName() + ",handlerNums=" + handlerNums);
}
}
//模拟并发调用Main类
public class Main {
static DuiwaiApi api = new DuiwaiApi();
public static void main(String[] args) {
Runnable r = new Runnable() {
public void run() {
// 调用对外API
api.add();
}
};
// 多线程 执行可以看到 对外API中的pv数据乱了
for (int i = 0; i < 10; i++) {
Thread t1 = new Thread(r);
t1.start();
}
}
}
运行上面demo,没有锁,可以发现,处理请求数handlerNums数据乱了。
2、利用cas实现锁
上面的程序handlerNums错乱了怎么办?我们可以用cas来实现个锁。使handlerNums可以正常累加。
场景变化:提供对外API,由于服务资源不够,同一时间只能处理同一个请求,当多个请求进入时,拿到锁的请求进行处理,并且累加计算handlerNums,没拿到锁的请求直接拒绝,并且不累加handlerNums。
//锁实现类
public class Suo1Simple {
Unsafe unsafe;
long markoffer;// mark在内存中的位置
int mark = 0;// 标志位0没锁,1有锁
public Suo1Simple() {
// 获取unsafe
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
this.unsafe = (Unsafe) f.get(null);
// 初始化 makr变量在内存中的位置
markoffer = unsafe.objectFieldOffset(this.getClass().getDeclaredField("mark"));
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
public boolean lock() {
//设置mark,为0(没锁)的话 设置为1(加锁)。
boolean b = unsafe.compareAndSwapInt(this, markoffer, 0, 1);
//返回true则加锁成功,返回false加锁失败
return b;
}
public boolean unlock() {
return unsafe.compareAndSwapInt(this, markoffer, 1, 0);
}
}
重要:unsafe. compareAndSwapInt(Object o, long offset, int expected,int x),底层调用cas,是安全的。作用:在内存中的对象o, offset位置,放入x值。如果offset值为0,则放入成功返回true。如果offer位置的值不为0,返回false,赋值失败。
修改DuiWaiApi类
//对外API
public class DuiwaiApi {
int handlerNums = 0;
Suo1Simple suo = new Suo1Simple();
//请求进来进行累加
public void add() {
// 加锁 如果成功则向下执行,否则不执行退出
if (suo.lock()) {
handlerNums++;
System.out.println(Thread.currentThread().getName() + "拿到了锁开始执行,handlerNums=" + handlerNums);
// 拿到锁的线程 执行完成后释放锁
suo.unlock();
} else {
System.out.println(Thread.currentThread().getName() + "没拿到锁退出");
}
}
}
执行后,可以看到handlerNums能正常累加了,不会出现错乱的数据。一般情况就可以使用cas来实现锁。
3、实现可以阻塞的锁
我们再把业务场景改变一下。虽然我们同时只能处理一个请求,但是没拿到锁的线程被拒绝了没有累加。这个锁能不能阻塞一下啊,前一个线程处理累加完,后面的线程也要进行处理累加。
修改刚才DuiwaiApi的代码,重写lock方法
// 锁
public boolean lock() {
while (true) {
boolean b = unsafe.compareAndSwapInt(this, markoffer, 0, 1);
if (b) {
return b;
}
}
}
可以看到我们在lock方法中增加了while语句。没拿到锁的线程会阻塞在lock中。一直去循环拿锁
3.1、自旋锁、乐观锁、不可重入锁
自旋锁:在lock方法中,多个线程会设置mark,设置失败的会一直循环去拿锁。这就是自旋锁。
乐观锁:调用lock方法,虽然会阻塞,但是没拿到锁的线程会一直循环去拿锁,他认为,拿到锁的线程会马上执行完毕,释放锁,这样他马上就能拿到锁了,不用休息,减少了线程切换。这就是乐观锁。
不可重入锁:大家注意对外api类中,调用了suo.lock方法。注意已经拿到锁的线程,再次调用lock线程(看下图),那么还会阻塞。这说明,我们实现的锁是不可重入的。 这个就是不可重入锁。
4、增加可重入功能
在上面可以看到,拿到锁的线程,不能再次调用lock方法,否则会阻塞。那么我们来优化一下。增加可重入功能。当线程拿到锁后,我们记录一下拿到锁的线程,那么当拿到锁的线程多次 执行lock时,我们不阻塞直接放行。
修改锁的实现类
// 增加属性getsuoThread :记录获取锁的线程
private Thread getsuoThread = null;
//修改lock方法
public boolean lock() {
while (true) {
//判断是拿到锁的线程进来了,则直接返回true
if (this.getsuoThread == Thread.currentThread()) {
System.out.println(this.getsuoThread.getName() + "重入了");
return true;
}
// 1、第一次会走这块 设置锁
boolean b = unsafe.compareAndSwapInt(this, markoffer, 0, 1);
if (b) {
// 2、设置成功说明拿到了锁,则记录一下获取锁的线程,
this.getsuoThread = Thread.currentThread();
return b;
}
}
}
// 释放锁
public boolean unlock() {
boolean b = unsafe.compareAndSwapInt(this, markoffer, 1, 0);
this.getsuoThread = null;
return b;
}
4.1可重入锁:
注意代码中红色部分,当拿到锁的线程,再次进入lock后,会直接返回ture。这个就是可重入锁。
5、利用wait和notify实现可等待唤醒的锁
通过刚才的demo,可以看到如果每个线程,执行的都很快,能快速拿、快速执行并释放,那么我们线程不休息,循环去争抢锁,会减少线程的上线文切换,提高效率。但是 如果每个线程拿到锁后,执行的很慢怎么办?其他线程不休息,一直在争抢,那么会给系统带来很大的开销。
刚才的demo大家可以在duiwaiapi类中而增加Thread.sleep,并增加调用的线程数。
修改后会发现不但程序慢,我们的系统也变的很卡,就是因为线程过多,一直在询问是否加锁成功,争抢cpu资源导致。这时,我们可以利用wait和notify来优化它。
修改锁实现类,重写lock和unlock方法(注意这里为减少代码行数,把可重入功能去掉了,大家理解后,可自行加入)
public boolean lock() {
while (true) {
boolean b = unsafe.compareAndSwapInt(this, markoffer, 0, 1);
if (b) {
return b;
} else {
//没拿到锁,程序进入等待,等拿到锁线程 进行唤醒再继续运行
synchronized (this) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
// 释放锁
public boolean unlock() {
// 拿到锁的线程 执行完毕,唤醒其他线程继续执行
synchronized (this) {
// 修改标志位 无锁状态
unsafe.compareAndSwapInt(this, markoffer, 1, 0);
// 唤醒 线程继续执行wait下面的方法
this.notify();
}
return true;
}
可以看到lock方法,当执行到synchronized时,所以所有的线程都会被排队,然后线程会排队一个一个的执行。synchronized里面的内容。当其中一个线程执行到wait后,则此线程进入休息状态,等待拿到锁的线程释放时,会调用notify来唤醒。
小提示:wait/notify只是举个例子,我们也可以使用park/unpark(他也是使用unsafe实现的),或者siginal和await.
5.1悲观锁:
每次都会悲观的认为其他线程修改了数据,所以每次执行时都会上锁,其他线程会被阻塞休息,直到释放锁,不像乐观锁,不休息一直询问。synchronized修饰的方法或代码块都是悲观锁。