并发编程
进程&线程
对于基本的概念,大家应该都很熟悉了,进程是资源分配的单位,线程是CPU调度的单位,线程是进程中的一个实体。
对于我们的Java程序来说,天生就是多线程的,我们通过main方法启动,就是启动了一个JVM的进程,同时创建一个名为main
的线程,main就是JVM进程中的一个实体线程。
线程生命周期
线程几种基本状态:
- New,初始状态,就是New了一个线程,但是还没有调用start方法
- Runnable,可运行Ready或者运行Running状态,线程的就绪和运行中状态我们统称为Runnable运行状态
- Blocked/Wating/Timed_Wating,这些状态统一就叫做休眠状态
- Terminated,终止状态
几个状态之间的转换我们分别来说。
New:我们创建一个线程,但是线程没有调用start方法,就是初始化状态。
Runnable:调用start()启动线程进入Ready可运行状态,等待CPU调度之后进入到Running状态。
Blocked:阻塞状态,当线程在等待进入synchronized锁的时候,进入阻塞状态。
Waiting:等待状态需要被显示的唤醒,进入该状态分为三种情况,在synchonized中调用Object.wait(),调用Thread.join(),调用LockSupport.park()。
Timed_Waiting:和Waiting的区别就是多了超时时间,不需要显示唤醒,达到超时时间之后自动唤醒,调用图中的一些带有超时参数的方法则会进入该状态。
Terminated:终止状态,线程执行完毕。
守护线程&用户线程
Java中的线程分为守护线程和用户线程,上面我们提到的main线程其实就是一个用户线程。
他们最主要的区别就在于,只要有非守护线程没有结束,JVM就不会正常退出,而守护线程则不会影响JVM的退出。
可以通过简单的方法设置一个线程为守护线程。
Thread t = new Thread(); t.setDaemon(true);
锁
锁是控制多线程并发访问共享资源的方式,为了更简单快速的了解Java中的锁,我们可以按照显示锁和隐式锁来做一个大致的区分。
锁
隐式锁
在没有Lock
接口之前,加锁通过synchronzied实现,在之前的Java基础系列中我已经说过了,就不在这里过多的阐述,此处引用之前写过的,更多详细可以看《我想进大厂》之Java基础夺命连环16问。
synchronized是java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁,使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,他依赖操作系统底层互斥锁实现,主要作用就是实现原子性操作和解决共享变量的内存可见性问题。
执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。
执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。
如果再深入到源码来说,synchronized实际上有两个队列waitSet和entryList。
- 当多个线程进入同步代码块时,首先进入entryList
- 有一个线程获取到monitor锁后,就赋值给当前线程,并且计数器+1
- 如果线程调用wait方法,将释放锁,当前线程置为null,计数器-1,同时进入waitSet等待被唤醒,调用notify或者notifyAll之后又会进入entryList竞争锁
- 如果线程执行完毕,同样释放锁,计数器-1,当前线程置为null
显示锁
虽然synchronized使用简单,但是也使得加锁的流程固化了,显示锁在Java1.5版本之后加入了Lock接口,可以通过声明式显示的加锁和解锁。
Lock lock = new ReentrantLock(); lock.lock(); //加锁 lock.unlock(); //解锁
独占锁
在上述的伪代码中,我们使用到了ReentrantLock
,它其实就是独占锁,独占锁保证任何时候都只有一个线程能获得锁,当然了,synchronized也是独占锁。
这里我们看ReentrantLock的几个加锁接口。
void lock(); //阻塞加锁 void lockInterruptibly() throws InterruptedException; //可中断 boolean tryLock(); //非阻塞 boolean tryLock(long time, TimeUnit unit) throws InterruptedException; //超时加锁
这几个加锁接口,向我们明白地展示了他和synchronized的区别。
- 可中断加锁
lockInterruptibly
,synchronized可能会有死锁的问题,那么解决方案就是能响应中断。当前线程加锁时,如果其他线程调用当前线程的中断方法,则会抛出异常。 - 非阻塞加锁
tryLock
,调用后立刻返回,获取锁则返回true,否则返回false - 支持超时加锁
tryLock(long time, TimeUnit unit)
,超时时间内获取锁返回true,否则返回false - 支持公平和非公平锁,公平指的是获取锁按照请求锁的时间顺序决定,先到先得,非公平则是直接竞争锁,先到不一定先得
- 支持Condition
如果你看过阻塞队列的源码,那么你对 Condition 应该挺了解了,我们举个栗子来看看,我们需要实现:
- 如果队列满了,那么写入阻塞
- 如果队列空了,那么删除(取元素)阻塞
我们给阻塞队列提供一个 put 写入元素和 take 删除元素的方法。
put 时候加锁且响应中断,如果队列满了,notFull.await 释放锁,进入阻塞状态,反之,则把元素添加到队列中,notEmpty.signal 唤醒阻塞在删除元素的线程。
take 的时候一样加锁且响应中断,如果队列空了,notEmpty.await 进入释放锁,进入阻塞状态,反之,则删除元素,notFull.signal 唤醒阻塞在添加元素的线程。
public class ConditionTest { public static void main(String[] args) throws Exception { ArrayBlockingQueue arrayBlockingQueue = new ArrayBlockingQueue(10); } static class ArrayBlockingQueue<E> { private Object[] items; int takeIndex; int putIndex; int count; private ReentrantLock lock; private Condition notEmpty; private Condition notFull; public ArrayBlockingQueue(int capacity) { this.items = new Object[capacity]; lock = new ReentrantLock(); notEmpty = lock.newCondition(); notFull = lock.newCondition(); } public void put(E e) throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == items.length) { notFull.await(); } enqueue(e); } finally { lock.unlock(); } } private void enqueue(E x) { final Object[] items = this.items; items[putIndex] = x; if (++putIndex == items.length){ putIndex = 0; } count++; notEmpty.signal(); } public E take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == 0) { notEmpty.await(); } return dequeue(); } finally { lock.unlock(); } } private E dequeue() { final Object[] items = this.items; E x = (E) items[takeIndex]; items[takeIndex] = null; if (++takeIndex == items.length){ takeIndex = 0; } count--; notFull.signal(); return x; } } }
读写锁
读写锁,也可以称作共享锁,区别于独占锁,共享锁则可以允许多个线程同时持有,如ReentrantReadWriteLock
允许多线程并发读,要简单概括就是:读读不互斥,读写互斥,写写互斥。
ReentrantReadWriteLock
通过阅读源码发现它内部维护了两个锁:读锁和写锁。
private final ReentrantReadWriteLock.ReadLock readerLock; private final ReentrantReadWriteLock.WriteLock writerLock;
本质上,不管是ReentrantLock还是ReentrantReadWriteLock都是基于AQS,AQS只有一个状态位state,对于ReentrantReadWriteLock实现读锁和写锁则是对state做出了区分,高16位表示的是读锁的状态,低16表示的是写锁的状态。
我们可以看一个源码中给出的使用例子。
class CacheData { Object data; volatile boolean cacheValid; final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); void processCachedData() { rwl.readLock().lock(); if (!cacheValid) { // 必须先释放读锁,再加写锁 rwl.readLock().unlock(); rwl.writeLock().lock(); try { // 重新校验状态,防止并发问题 if (!cacheValid) { data = ... cacheValid = true; } // 写锁降级为读锁 rwl.readLock().lock(); } finally { rwl.writeLock().unlock(); // 写锁释放,仍然持有读锁 } } try { use(data); } finally { rwl.readLock().unlock(); } } }
这个例子嵌套写的其实不太好理解,因为他包含了一个写锁降级的概念,实际上我们自己写最简单的例子就是这样,例子中给到的示例其实是一个意思,只是在写锁释放前先降级为读锁,明白意思就好。
rwl.readLock().lock(); doSomething(); rwl.readLock().unlock(); rwl.writeLock().lock(); doSomething(); rwl.writeLock().unlock();
额外需要注意的是,写锁可以降级为读锁,但是读锁不能升级为写锁,比如下面这种写法是不支持的。
rwl.readLock().lock(); doSomething(); rwl.writeLock().lock(); doSomething(); rwl.writeLock().unlock(); rwl.readLock().unlock();
StampedLock
这是JDK1.8之后新增的一个锁,相比ReentrantReadWriteLock
他的性能更好,在读锁和写锁的基础上增加了一个乐观读锁。
写锁:他的写锁基本上和ReentrantReadWriteLock一样,但是不可重入。
读锁:也和ReentrantReadWriteLock一样,但是不可重入。
乐观读锁:普通的读锁通过CAS去修改当前state状态,乐观锁实现原理则是加锁的时候返回一个stamp(锁状态),然后还需要调用一次validate(stamp)
判断当前是否有其他线程持有了写锁,通过的话则可以直接操作数据,反之升级到普通的读锁,之前我们说到读写锁也是互斥的,那么乐观读和写就不是这样的了,他能支持一个线程去写。所以,他性能更高的原因就来自于没有CAS的操作,只是简单的位运算拿到当前的锁状态stamp,并且能支持另外的一个线程去写。
总结下来可以理解为:读读不互斥,读写不互斥,写写互斥,另外通过tryConvertToReadLock()
和tryConvertToWriteLock()
等方法支持锁的升降级。
还是按照官方的文档举个栗子,方便理解,两个方法分别表示乐观锁的使用和锁升级的使用。
public class StampedLockTest { private double x, y; private final StampedLock sl = new StampedLock(); double distanceFromOrigin() { // 乐观锁 long stamp = sl.tryOptimisticRead(); double currentX = x, currentY = y; if (!sl.validate(stamp)) { //状态已经改变,升级到读锁,重新读取一次最新的数据 stamp = sl.readLock(); try { currentX = x; currentY = y; } finally { sl.unlockRead(stamp); } } return Math.sqrt(currentX * currentX + currentY * currentY); } void moveIfAtOrigin(double newX, double newY) { // 可以使用乐观锁替代 long stamp = sl.readLock(); try { while (x == 0.0 && y == 0.0) { // 尝试升级到写锁 long ws = sl.tryConvertToWriteLock(stamp); if (ws != 0L) { //升级成功,替换当前stamp标记 stamp = ws; x = newX; y = newY; break; } else { //升级失败,再次获取写锁 sl.unlockRead(stamp); stamp = sl.writeLock(); } } } finally { sl.unlock(stamp); } } }
LockSupport
LockSupport是一个比较基础的工具类,基于Unsafe
实现,主要就是提供线程阻塞和唤醒的能力,上面我们提到对线程生命周期状态的时候也说过了,LockSupport的几个park功能将会把线程阻塞,直到被唤醒。
看看他的几个核心方法:
public static void park(); //阻塞当前线程 public static void parkNanos(long nanos); //阻塞当前线程加上了超时时间,达到超时时间之后返回 public static void parkUntil(long deadline); //和上面类似,参数deadline代表的是从1970到现在时间的毫秒数 public static void unpark(Thread thread);// 唤醒线程
举个栗子:
public class Test { public static void main(String[] args) throws Exception { int sleepTime = 3000; Thread t = new Thread(() -> { System.out.println(Thread.currentThread().getName() + "挂起"); LockSupport.park(); System.out.println(Thread.currentThread().getName() + "继续工作"); }); t.start(); System.out.println("主线程sleep" + sleepTime); Thread.sleep(sleepTime); System.out.println("主线程唤醒阻塞线程"); LockSupport.unpark(t); } } //输出如下 主线程sleep3000 Thread-0挂起 主线程唤醒阻塞线程 Thread-0继续工作