JUC并发工具类

198 阅读10分钟

1 Lock和Condition

并发编程要解决的三个问题:分工,互斥,同步。Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题。

Java 语言本身提供的 synchronized 也是管程的一种实现,为什么JDK实现了管程还要在SDK外(Lock 和 Condition )提供另外的实现?

再造管程的理由 synchronized 没有办法解决死锁的破坏不可抢占条件。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。

对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。

能够破坏不可抢占的互斥锁的设计有三个方案,或者从以下三个方面去考虑

  • 能够响应中断。synchronized 的问题是,持有锁 A 后,如果尝试获取锁 B 失败,那么线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号,也就是说当我们给阻塞的线程发送中断信号的时候,能够唤醒它,那它就有机会释放曾经持有的锁 A。这样就破坏了不可抢占条件了。
  • 支持超时。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
  • 非阻塞地获取锁。如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。

这三个方案体现在Lock的api上,这也是为啥在synchronized外还需要Lock的理由

public interface Lock {
// 支持中断
void lockInterruptibly() throws InterruptedException;
// 支持超时的API
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
//支持非阻塞获取锁的API
boolean tryLock();
}

Lock如何保证可见性 Happens-Before规则有一条是synchronized 的解锁 Happens-Before 于后续对这个锁的加锁。其中并没有提到lock的解锁先于加锁。那使用lock是怎么保证可见性的呢?

class X {
	private final Lock rtl =new ReentrantLock();
	int value;
	public void addOne() {
               //获取锁
		rtl.lock();
		try {
			value+=1;
 		} finally {
                        //保证锁能释放
 			rtl.unlock();
		}
	}
}

以上示例代码,value+=1是否可见于下一个加锁的线程呢,是的话又是怎么做到的呢。

class SampleLock {
	volatile int state;
       //加锁
	lock() {
       //省略代码无数
	state = 1;
	}
       //解锁
	unlock() {
       //省略代码无数
	state = 0;
	}
}
  1. 顺序性规则:对于线程 T1,value+=1 Happens-Before 释放锁的操作 unlock();
  2. volatile 变量规则:由于 state = 1 会先读取 state(后于unlock的写),所以线程 T1 的 unlock() 操作Happens-Before 线程 T2 的 lock() 操作;
  3. 传递性规则:线程 T1 的 value+=1 Happens-Before 线程 T2 的 lock() 操作。

1.1 ReentrantLock 可重入锁

可重入锁,顾名思义,指的是线程可以重复获取同一把锁。

还有个概念类似的叫可重入函数。指的是多个线程可以同时调用该函数,所有线程都能得到正确结果。同时在一个线程内支持线程切换,无论被切换多少次,结果都是正确的。这样的函数是堆栈封闭的,天然也线程安全。

class X {
	private final Lock rtl =new ReentrantLock();
	int value;
	public int get() {
        //获取锁 ②
		rtl.lock();
	
		try {
			return value;
		} finally {
			//保证锁能释放
			rtl.unlock();
		}
	}
	public void addOne() {
       //获取锁
		rtl.lock();
		try {
			value = 1 + get(); ①
		} finally {
		//保证锁能释放
			rtl.unlock();
		}
	}
}

公平锁和非公平锁 ReentrantLock无参构造函数,默认是new一个非公平锁。还有一个构造参数,通过传入的fair布尔值 决定创建公平锁还是非公平锁

//无参构造函数:默认非公平锁
public ReentrantLock() {
	sync = new NonfairSync();
}
//根据公平策略参数创建锁
public ReentrantLock(boolean fair){
	sync = fair ? new FairSync():new NonfairSync();
}

锁都对应一个入口等待队列,如果一个线程没有获得锁,就会进入等待队列,当有线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程。

image.png

  • 公平锁 从入口等待队列中唤醒的策略是谁等待时间长,就唤醒谁
  • 非公平锁 不根据等待时间来进行唤醒

1.2 Condition

image.png

synchronized 只有一个条件变量,而而 Lock&Condition 实现的管程是支持多个条件变量的。

支持多个条件变量的管程可以让我们的代码可读性更好,实现也更容易。实现阻塞队列

public class BlockedQueue<T>{
	final Lock lock =new ReentrantLock();
//条件变量:队列不满
	final Condition notFull =lock.newCondition();
//条件变量:队列不空
	final Condition notEmpty =lock.newCondition();
//入队
	void enq(T x) {
		lock.lock();
		try {
			while (队列已满){
				//等待队列不满
				notFull.await();
			}
			//省略入队操作...
			//入队后,通知可出队
			notEmpty.signal();
		}finally {
			lock.unlock();
		}
	}
	//出队
	void deq(){
		lock.lock();
		try {
			while (队列已空){
	//等待队列不空
				notEmpty.await();
			}
	//省略出队操作...
	//出队后,通知可入队
			notFull.signal();
		}finally {
			lock.unlock();
		}
	}
}

Lock 和 Condition 实现的管程,线程等待和通知需要调用await()、signal()、signalAll(),它们的语义和 wait()、notify()、notifyAll() 是相同的。但是不一样的是,Lock&Condition 实现的管程里只能使用前面的 await()、signal()、signalAll(),而后面的 wait()、notify()、notifyAll() 只有在 synchronized 实现的管程里才能使用。如果一不小心在 Lock&Condition 实现的管程里调用了 wait()、notify()、notifyAll(),那程序可就彻底玩儿完了。

同步与异步

通俗点来讲就是调用方是否需要等待结果,如果需要等待结果,就是同步;如果不需要等待结果,就是异步。

我们工作中经常用到的 RPC 调用,在 TCP 协议层面,发送完 RPC 请求后,线程是不会等待 RPC 的响应结果的。从开发者的直观看,觉得RPC调用是同步的,但是实际上RPC调用是异步的,RPC框架做了异步转同步的工作。

Dubbo实现异步转同步

调用线程通过调用 get() 方法等待 RPC 返回结果,这个方法里面,调用 lock() 获取锁,在 finally 里面调用 unlock() 释放锁;获取锁后,通过经典的在循环中调用 await() 方法来实现等待。当 RPC 结果返回时,会调用 doReceived() 方法,这个方法里面,调用 lock() 获取锁,在finally 里面调用 unlock() 释放锁,获取锁后通过调用 signal() 来通知调用线程,结果已经返回,不用继续等待了。

2 Semaphore 信号量

信号量模型还是很简单的,可以简单概括为:一个计数器,一个等待队列,三个方法。计数器和等待队列对外是透明的,所以只能通过信号量模型提供的三个方法来访问它们,这三个方法分别是:init()、down() 和 up()。 image.png

  • init():设置计数器的初始值。
  • down():计数器的值减 1;如果此时计数器的值小于 0,则当前线程将被阻塞(入队),否则当前线程可以继续执行。
  • up():计数器的值加 1;如果此时计数器的值小于或者等于 0,则唤醒等待队列中的一个线程,并将其从等待队列中移除。

在 Java SDK 并发包里,down() 和 up() 对应的则是 acquire() 和 release()。

// Semaphore实现限流器示例代码
class ObjPool<T, R> {
	final List<T> pool;
	//用信号量实现限流器
	final Semaphore sem;
	//构造函数
	ObjPool(int size, T t){
		pool = new Vector<T>(){};
		for(int i=0; i<size; i++){
			pool.add(t);
		}
		sem = new Semaphore(size);
	}
   //利用对象池的对象,调用func
	R exec(Function<T,R> func) {
		T t = null;
		sem.acquire();
		try {
			t = pool.remove(0);
			return func.apply(t);
		} finally {
			pool.add(t);
			sem.release();
		}
	}
}
//创建对象池
ObjPool<Long, String> pool =new ObjPool<Long, String>(10, 2);
//通过对象池获取t,之后执行
pool.exec(t -> {
	System.out.println(t);
	return t.toString();
});

3 ReadWriteLock 读写锁

读写锁的应用场景: 读多写少场景。缓存就是一种典型的读多写少的场景。 读写锁遵循的三原则

  1. 允许多个线程同时读共享变量;
  2. 只允许一个线程写共享变量;
  3. 如果一个写线程正在执行写操作,此时禁止读线程读共享变量。

读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。但读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作。

4 StampedLock 比读写锁更快的锁

在读多写少的场景中,还有没有更快的技术方案呢?还真有,Java 在 1.8 这个版本里,提供了一种叫 StampedLock 的锁,它的性能就比读写锁还要好。

StampedLock与ReadWriteLock的异同 差别的地方

  1. ReadWriteLock只有两种模式,读锁和写锁。StampedLock 支持三种模式,分别是:写锁、悲观读锁和乐观读。
  2. StampedLock 里的写锁和悲观读锁加锁成功之后,都会返回一个 stamp;然后解锁的时候,需要传入这个 stamp。
  3. StampedLock 提供的乐观读,是允许一个线程获取写锁的,也就是说不是所有的写操作都被阻塞。ReadWriteLock 支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作会被阻塞;乐观读这个操作是无锁的,不是“乐观读锁”,无锁的性能肯定比有锁性能好。
  4. StampedLock 的功能仅仅是 ReadWriteLock 的子集。StampedLock 不支持重入。StampedLock 的悲观读锁、写锁都不支持条件变量,
final StampedLock sl =new StampedLock();
//获取 释放悲观读锁示意代码
long stamp = sl.readLock();
try {
//省略业务相关代码
} finally {
	sl.
	unlockRead(stamp);
}
//获取 释放写锁示意代码
long stamp = sl.writeLock();
try {
//省略业务相关代码
} finally {
	sl.
	unlockWrite(stamp);
}

相同的地方

  1. StampedLock写锁、悲观读锁的语义和 ReadWriteLock的写锁、读锁的语义非常类似,允许多个线程同时获取悲观读锁,但是只允许一个线程获取写锁,写锁和悲观读锁是互斥的。

使用StampedLock的JDK官方示例代码

class Point {
    private int x, y;
    final StampedLock sl = new StampedLock();

    //计算到原点的距离
    int distanceFromOrigin() {
    //乐观读
        long stamp = sl.tryOptimisticRead();
    //读入局部变量,
    //读的过程数据可能被修改
        int curX = x, curY = y;
    //判断执行读操作期间,
    //是否存在写操作,如果存在,则sl.validat返回false
        if (!sl.validate(stamp)) {
        //升级为悲观读锁
            stamp = sl.readLock();
            try {
                curX = x;
                curY = y;
            } finally {
        //释放悲观读锁
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(curX * curX + curY * curY);
    }
}

在上面这个代码示例中,如果执行乐观读操作的期间,存在写操作,会把乐观读升级为悲观读锁。这个做法挺合理的,否则你就需要在一个循环里反复执行乐观读,直到执行乐观读操作的期间没有写操作(只有这样才能保证 x 和 y 的正确性和一致性),而循环读会浪费大量的 CPU。

使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。因为线程阻塞在 StampedLock 的 readLock() 或者writeLock() 上时,此时调用该阻塞线程的 interrupt() 方法,会导致 CPU 飙升到100% (详细原因待补充)。