并发编程从入门到放弃系列开始和结束(一)

简介: 对于 Java 部分的面试来说,突然想到并发这一块的内容是不太完整的,这篇文章会通篇把多线程和并发都大致阐述一遍,至少能够达到了解原理和使用的目的,内容会比较多,从最基本的线程到我们常用的类会统一说一遍,慢慢看。

de417e5d9205e5fbbc851328a063ce57.jpg

并发编程

进程&线程

对于基本的概念,大家应该都很熟悉了,进程是资源分配的单位,线程是CPU调度的单位,线程是进程中的一个实体。

对于我们的Java程序来说,天生就是多线程的,我们通过main方法启动,就是启动了一个JVM的进程,同时创建一个名为main的线程,main就是JVM进程中的一个实体线程。

22b35702d123246f1bd085f07ee5b3a5.jpg

线程生命周期

线程几种基本状态:

  1. New,初始状态,就是New了一个线程,但是还没有调用start方法
  2. Runnable,可运行Ready或者运行Running状态,线程的就绪和运行中状态我们统称为Runnable运行状态
  3. Blocked/Wating/Timed_Wating,这些状态统一就叫做休眠状态
  4. Terminated,终止状态

几个状态之间的转换我们分别来说。

fde9dd96532bd8395ae660fd878ea7cd.jpg

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中的锁,我们可以按照显示锁和隐式锁来做一个大致的区分。

93ccf7895e84715a1db0ca4765468857.jpg

隐式锁

在没有Lock接口之前,加锁通过synchronzied实现,在之前的Java基础系列中我已经说过了,就不在这里过多的阐述,此处引用之前写过的,更多详细可以看《我想进大厂》之Java基础夺命连环16问

synchronized是java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁,使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,他依赖操作系统底层互斥锁实现,主要作用就是实现原子性操作和解决共享变量的内存可见性问题。

执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。

执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。

如果再深入到源码来说,synchronized实际上有两个队列waitSet和entryList。

  1. 当多个线程进入同步代码块时,首先进入entryList
  2. 有一个线程获取到monitor锁后,就赋值给当前线程,并且计数器+1
  3. 如果线程调用wait方法,将释放锁,当前线程置为null,计数器-1,同时进入waitSet等待被唤醒,调用notify或者notifyAll之后又会进入entryList竞争锁
  4. 如果线程执行完毕,同样释放锁,计数器-1,当前线程置为null

dde89f33eaedf2ac60cf3c4cf3cf73a9.jpg

显示锁

虽然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的区别。

  1. 可中断加锁lockInterruptibly,synchronized可能会有死锁的问题,那么解决方案就是能响应中断。当前线程加锁时,如果其他线程调用当前线程的中断方法,则会抛出异常。
  2. 非阻塞加锁tryLock,调用后立刻返回,获取锁则返回true,否则返回false
  3. 支持超时加锁tryLock(long time, TimeUnit unit),超时时间内获取锁返回true,否则返回false
  4. 支持公平和非公平锁,公平指的是获取锁按照请求锁的时间顺序决定,先到先得,非公平则是直接竞争锁,先到不一定先得
  5. 支持Condition

如果你看过阻塞队列的源码,那么你对 Condition 应该挺了解了,我们举个栗子来看看,我们需要实现:

  1. 如果队列满了,那么写入阻塞
  2. 如果队列空了,那么删除(取元素)阻塞

我们给阻塞队列提供一个 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继续工作


相关文章
|
6月前
|
安全 Java 调度
Java并发编程学习1-并发简介
本篇介绍并发简介,带大家走近Java并发编程的世界
52 0
Java并发编程学习1-并发简介
|
7月前
|
算法 编译器 程序员
[笔记]C++并发编程实战 《一》你好,C++的并发世界(二)
[笔记]C++并发编程实战 《一》你好,C++的并发世界(二)
|
7月前
|
安全 程序员 API
[笔记]C++并发编程实战 《一》你好,C++的并发世界(一)
[笔记]C++并发编程实战 《一》你好,C++的并发世界
并发编程进阶
并发编程进阶
|
8月前
|
存储 缓存 算法
并发编程基础
并发编程基础
|
11月前
|
缓存 安全
并发编程学习一
并发编程学习一
50 0
|
12月前
|
缓存 安全 Java
【并发编程】JAVA并发编程面试题合集
【并发编程】JAVA并发编程面试题合集
【并发编程】JAVA并发编程面试题合集
|
存储 缓存 安全
并发编程原理扫盲笔记
垃圾回收,顾名思义,便是将已经分配出去的,但却不再使用的内存回收回来,以便能够再次分配。在 Java 虚拟机的语境下,垃圾指的是死亡的对象所占据的堆空间。
106 0
|
Java 调度
并发编程从入门到放弃系列开始和结束(四)
对于 Java 部分的面试来说,突然想到并发这一块的内容是不太完整的,这篇文章会通篇把多线程和并发都大致阐述一遍,至少能够达到了解原理和使用的目的,内容会比较多,从最基本的线程到我们常用的类会统一说一遍,慢慢看。
并发编程从入门到放弃系列开始和结束(四)
|
缓存 安全 Java
并发编程从入门到放弃系列开始和结束(二)
对于 Java 部分的面试来说,突然想到并发这一块的内容是不太完整的,这篇文章会通篇把多线程和并发都大致阐述一遍,至少能够达到了解原理和使用的目的,内容会比较多,从最基本的线程到我们常用的类会统一说一遍,慢慢看。
并发编程从入门到放弃系列开始和结束(二)