Java并发高级主题

80 阅读15分钟

Java中的锁

Lock接口

在 Java SE 5 后,Java 并发包 java.util.concurrent 中新增了 Lock 接口及其相关实现类,如:ReentrantLock 来实现锁的功能,它提供了与 synchronized 相似的同步功能,不过在使用时需要显示的获取锁和释放锁,虽然从这个角度来看,使用 Lock 接口更为麻烦,不过我们可以通过 Lock 接口的实现类,实现以下功能:

  • 尝试非阻塞的获取锁,即 tryLock()

    • tryLock() 方法会尝试非阻塞的获取锁,即调用方法后不管能否取到锁都会立即返回,不会被阻塞
  • 能被中断的获取锁

    • 与 synchronied 不同,获取到锁的线程能相应中断,当线程被中断时,中断异常会被抛出,并且锁也会被释放
  • 超时获取锁,即 tryLock(long time, TimeUnit unit)

示例

Lock lock = new ReentrantLock();
lock.lock();
try {
    // 同步代码块
} finally {
    lock.unlock(); // 千万不能忘记在finally块中释放锁
}

API

/* 构造方法 */
public ReentrantLock(boolean fair) { // fair默认是false
    sync = fair ? new FairSync() : new NonfairSync();
}

/* 重要方法 */
void lock()
void lockInterruptibly() throws InterruptedException
boolean tryLock()
boolean tryLock(long time, TimeUnit unit) throws InterruptedException
void unlock()
Condition newCondition()

3个高级的lock方法

轮询锁

tryLock()

  • 只有在锁没有被其他线程拿到时才获取锁,然后返回true,否则返回false,会立即返回,不会阻塞。

  • 不是可中断锁

  • 可以避免锁顺序死锁的发生

    • 死锁发生的一个典型示例就是锁顺序死锁,即(假设要进行一个转账操作)
    •  public boolean transferMoney(Account fromAcct, Account toAcct, double money) {
           synchronized (fromAcct) {
               synchronized (toAcct) {
                               // 转账
               }
           }
       }
       
       // 调用
       final Account A = new Account();
       final Account B = new Account();
       new Thread() {
           public void run() {
               transferMoney(A, B, 100)
           }
       }.start();
       
       new Thread() {
           public void run() {
               transferMoney(B, A, 100)
           }
       }.start();
       // 两个线程在进行方向相反的转账操作,及容易发生死锁!
      
    • 可以通过tryLock()的方式来避免锁顺序死锁
    •  public boolean transferMoney(Account fromAcct, Account toAcct, double money) {
           long fixedDelay = getFixedDelayComponentNanos(timeout, unit); // 固定时延部分
           long randMod = getRandomDelayModulusNanos(timeout, unit); // 随机时延部分
           long stopTime = System.nanoTime() + unit.toNanos(timeout); // 过期时间
           while (true) {
               if (fromAcct.lock.tryLock()) {
                   try {
                       if (toAcct.lock.tryLock()) { // 如果失败了,该线程会放开已经持有的锁,避免了死锁发生
                           try {
                               // 转账
                           } finally {
                               toAcct.lock.unlock();
                           }
                       }
                   } finally {
                       fromAcct.lock.unlock();
                   }
               }
               if (System.nanoTime() < stopTime) // 检查是否超时
                   return false;
               NANOSECONDS.sleep(fixedDelay + rnd.nextLong() % randMod); // 等待一定时长,防止陷入活锁
           }
       }
      

定时锁

tryLock(long time, TimeUnit unit)

  • 定时锁是可中断锁,你看它是能 throw InterruptedException 的,能抛出 InterruptedException 的方法都是阻塞方法
  • 等待 timeout 时间,再去 tryLock 锁

中断锁

lockInterruptibly()

  • 能在获得锁的同时保持对中断的响应,即在调用 lockInterruptibly() 获得锁之后,如果线程被 interrupt() 打上了中断标记,会抛中断异常
  • 相当于在同步代码块中加入了取消点

公平锁与非公平锁

ReentrantLock(boolean fair)

  • 公平锁: 在有线程持有锁和有线程在队列中等待锁的时候,会将新发出请求的线程放入队列中,而不是立即执行它,也就是说,获取锁的顺序和线程请求锁的顺序是一样的。

  • 非公平锁: 只当有线程持有锁时,新发出请求的线程才被放入队列中,如果新的线程到达时没有线程持有锁,但队列中有等待的线程(比如队列中的线程还在启动中,还没有拿到锁),这时新请求锁的线程会先于队列中的线程获取锁。

  • 非公平锁性能更优的原因:

    • 恢复一个被挂起的线程到这个线程真正运行起来之间,存在着巨大时时延
    • 在等待队列中的线程被恢复的超长时延里,如果正好进来了一个线程获取锁,非公平锁会让这个新进来的线程先执行,它很有可能能等待队列中的线程恢复运行前就执行完了,相当于时间不变的情况下,利用等待线程的恢复运行时延,多执行了一个线程
    • 只要当线程运行时间长,或锁的请求频率比较低时,才适合使用公平锁

Condition: newCondition()

在介绍 Condition 前,要先来介绍一下为什么需要 Condition,因此,需要先来介绍一下 “等待/通知机制”。

等待/通知机制

主要方法: 这些方法都是 Object 类的方法,因为 synchronized 可以将任意一个对象作为锁。

wait()             // 使调用该方法的线程释放锁,从运行状态中退出,进入等待队列,直到接到通知或者被中断
wait(long timeout) // 等待time毫秒内是否有线程对其进行了唤醒,如果超过这个时间则自动唤醒
notify()           // 随机唤醒等待队列中等待锁的一个线程,使该线程退出等待队列,进入可运行状态
notifyAll()        // 使所有线程退出等待队列,进入可运行状态,执行的顺序由JVM决定

注意

  • 在调用以上这些方法时,如果它们没有持有适当的锁,即不在同步代码块中,会抛出 IllegalMonitorStateException 异常(RuntimeException,不用 catch),同时调用 wait() 和 notify() 的方法也必须是同一个对象
  • 最好使用 notifyAll() 来唤醒等待线程,不然很容易发生死锁

wait 的线程是如何被其对应的 notify 通知到的?(等待/通知机制实现原理)

  • 每个锁对象都又两个队列,一个是就绪队列,一个是阻塞队列
  • 就绪队列中存储了将要获得锁的线程,阻塞队列中存储了被阻塞的线程
  • 一个线程被唤醒后,会进入就绪队列,等待 CPU 调度
  • 一个线程被 wait 后就会进入阻塞队列,等待其他线程调用 notify,它才会被选中进入就绪队列,等待被 CPU 调度

条件队列的标准使用形式

void stateDependentMethod() throws InterruptedException {
    synchronized (lock) {
        while(!conditionPredicate())
            lock.wait(); // 一个条件队列可能与多个条件相关,
                         // 我们并不知道notifyAll是针对哪一个条件的,
                         // 为了防止wait被过早唤醒,wait必须放在循环中!
    }
}

void stateAwakeMethod() {
    synchronized (lock) {
        lock.notifyAll(); // 不要使用notify!!!
                          // 一旦有一个notify错误的在wait前执行了,
                          // 将会有一个wait永远无法被唤醒!
    }
}

使用 wait 和 notifyAll 实现可重新关闭的阀门

public class ThreadGate {
    @GuardedBy("this") private boolean isOpen;
    @GuardedBy("this") private int generation;

    public synchronized void close() {
        isOpen = false;
    }

    public synchronized void open() {
        ++generation;
        isOpen = true;
        notifyAll();
    }
    
    public synchronized void await() throws InterruptedException {
        int arrivalGeneration = generation;
        // 如果阀门打开后很快就关闭了,那么这个while循环可能检测不到isOpen为true的状态,
        // 会一直阻塞在这里;添加一个generation,在open时该变它的值,
        // 这样只要open了一次,这个while循环就一直为false了,一定会放行线程!
        while (!isOpen && arrivalGeneration == generation)
            wait();
    }
}

通过观察上面:条件队列的标准使用形式,发现 “等待/通知机制” 由于不能指定条件,使用起来是很不方便的,因为我们不能控制 notify 唤醒的 wait 到底是哪一个,可能会导致:提前唤醒了正在 wait 的线程,然后本应该被唤醒的 wait 却没有被唤醒。这种时候,我们可以通过 Condition 来实现 wait 和 notify 的分堆,防止 notify 唤醒别人的 wait。

Condition

/* 获取Condition的方法 */
protected final Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();

/* Condition接口中的方法 */
void await() throws InterruptedException; // 相当于wait()
void awaitUninterruptibly();
long awaitNanos(long nanosTimeout) throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException; // 相当于wait(long timeout)
boolean awaitUntil(Date deadline) throws InterruptedException;
void signal(); // 相当于notify()
void signalAll(); // 相当于notifyAll()

有了 Condition 后,就可以选择使用 signal() 而不是 signalAll() 了,因为不用担心 signal() 会唤醒其他 await() 然后错过自己本该唤醒的 await() 了。这个时候使用 signal(),每次只会唤醒一个线程,能降低锁的竞争,减少上下问切换的次数,性能是要比 signalAll() 好的。

synchronized和ReentrantLock的选择

  • 选择方式

    • 只有当需要如下高级功能时才使用ReentrantLock,否则优先使用synchronized

      • 可轮询、可定时、可中断的锁
      • 公平锁
      • 非块结构
  • 优先选择synchronized的原因

    • Java 6开始,ReenstrantLock 和内置锁的性能相差不大
    • synchronized 是 JVM 的内置属性,未来更有可能对 synchronized 进行性能优化,如对线程封闭的锁对象的锁消除,增加锁的粒度等
    • ReenstrantLock 危险性更高(如忘记在 finally 块中 lock.unlock() 了,会导致锁永远无法被释放,出现问题,极难 debug)
    • 许多现有程序中已使用了 synchronized,两种方式混合使用比较易错

读写锁

特点:支持读操作并发执行、涉及到写操作时才线程间互斥执行。

方法:

  • 获得读锁:lock.readLock().lock()
  • 释放读锁:lock.readLock().unlock()
  • 获得写锁:lock.writeLock().lock()
  • 释放写锁:lock.writeLock().unlock()

Java中13个原子操作类

分类

  • 原子更新基本类型

    • AtomicBoolean
    • AtomicInteger
    • AtomicLong
  • 原子更新数组

    • AtomicIntegerArray
    • AtomicLongArray
    • AtomicReferenceArray
  • 原子更新引用类型

    • AtomicReference
    • AtomicReferenceFieldUpdater :原子更新引用类型里的字段
    • AtomicMarkableReference :原子更新带有标记位的引用类型
  • 原子更新字段类

    • AtomicIntegerFieldUpdater :原子更新 Integer 字段的更新器
    • AtomicLongFieldUpdater :原子更新长 Long 字段的更新器
    • AtomicStampedReference :原子更新带版本号的引用类型。即将整数的版本号值与引用关联起来,每一次更新都会改变版本号值,可以用来解决 CAS 中的 ABA 问题

AtomicInteger 的常用方法

// 原子方式将数值加delta,并返回
public final int addAndGet(int delta)

// 如果oldValue == expect,将它更新为update
public final boolean compareAndSet(int expect, int update)
    
// 类似i++操作,返回的是旧值
public final int getAndIncrement()
    
// 最终会设置为newValue,使用lazySet设置后,可能在之后的一段中,其他线程读到的还是旧值
public final void lazySet(int newValue)
    
// 原子方式设置新值,并返回旧值
public final int getAndSet(int newValue)

使用方法:

public class AtomicIntegerDemo {
    static AtomicInteger ai = new AtomicInteger(1);
    
    public static void main(String[] args) {
        System.out.println(ai.getAndIncrement());
        System.out.println(ai.get());
    }
}

AtomicIntegerArray的常用方法

// 对数组索引为i的元素进行addAndGet(int delta)操作
public final int addAndGet(int i, int delta)
    
// 对数组索引为i的元素进行compareAndSet(int expect, int update)操作
public final boolean compareAndSet(int i, int expect, int update)

使用方法:

public class AtomicIntegerArrayDemo {
    static int[] value = new int[] {1, 2};
    static AtomicIntegerArray ai = new AtomicIntegerArray(value);
    
    public static void main(String[] args) {
        ai.getAndSet(0, 3)
        System.out.println(ai.get(0));
        System.out.println(value[0]);
    }
}

AtomicReference使用示例

public class AtomicReferenceDemo {
    public static AtomicReference<User> ar = 
        new AtomicReference<User>();
    
    public static void main(String[] args) {
        User user = new User("user", 15);
        ar.set(user);
        System.out.println(ar.get().name);
        System.out.println(ar.get().old);
    }
    
    public static class User {
        public String name;
        public int old;
        
        public User(String name, int old) {
            this.name = name;
            this.old = old;
        }
    }
}    

AtomicIntegerFieldUpdater示例

想要原子的更新字段需要两步:

  • public static <U> AtomicIntegerFieldUpdater<U> newUpdater(Class<U> tclass, String fieldName) 得到更新器实例,设置想要进行原子更新的类和具体的属性
  • 更新类的字段必须使用 public volatile 修饰

示例:

public class AtomicIntegerFieldUpdaterDemo {
    private static AtomicIntegerFieldUpdater<User> updater = 
        AtomicIntegerFieldUpdater.newUpdater(User.class, "old");
    
    public static void main(String[] args) {
        User user = new User("user", 10);
        System.out.println(updater.getAndIncrement(user));
        System.out.println(user.old);
    }
    
    public static class User {
        public String name;
        public volatile int old;
        
        public User(String name, int old) {
            this.name = name;
            this.old = old;
        }
    }
}

非阻塞同步机制

对比锁与非阻塞同步机制

  • 优势:线程之间存在竞争时,锁能自动处理竞争问题,即让一个线程拿到锁执行,然后阻塞其他线程。

  • 劣势:线程被阻塞到恢复执行的过程中存在很大的性能开销。

    • 有一些智能的JVM会根据之前的操作对锁的持有时间的长短,判断是自旋等待还是挂起线程,以提高性能。

非阻塞同步:CAS

  • 输入:

    • 需要读写的内存位置V
    • 认为这个位置现在的值A
    • 想要写入的新值B
  • 输出:V位置以前的值(无论写入操作是否成功)

  • 含义:认为V处的值应该是A,如果是,把V处的值改为B,如果不是则不修改,然后把V处现在的值返回。

乐观锁与悲观锁

锁与CAS分别对应着悲观锁与乐观锁这两种不同的锁。它们的定义如下:

  • 悲观锁

    • 就是独占锁,假设最坏情况,同一时刻只允许一个线程执行
    • 适合写多读少,锁竞争严重的情况 (当资源竞争严重时,CAS 大概率会自旋,会浪费 CPU 资源)
  • 乐观锁

    • 借助冲突检查机制判断在更新状态的过程中有没有其他线程修改状态,如果有,更新操作失败,可以选择重试
    • 适合读多写少,资源竞争少的情况 (资源竞争少时,使用 synchronized 同步锁会进行线程的阻塞和唤醒,而 CAS 不需要切换线程,并且自旋概率较低)

非阻塞算法

一般通过使用原子变量类来实现非阻塞同步算法,因为它们有 compareAndSet 方法

非阻塞计数器

基于1个CAS

public class CasCounter {
    private SimulatedCAS value;
    
    public int getValue() {
        return value.get();
    }
    
    public int increment() {
        int v;
        // 以下3行为CAS的标准使用方式:
        // 1. 使用do-while循环,在do中先获取oldValue值
        // 2. 在while的判断中进行CAS操作,并将返回值与do语句块中获取的oldValue比较
        // 3. 直到CAS成功才结束循环
        do {
            v = value.get();
        } while (v != value.compareAndSwap(v, v + 1));
        return v + 1;
    }
}

非阻塞栈

基于1个CAS

通过链表实现,链表头是栈顶元素,在进行 push 和 pop 操作时,判断栈顶元素是否发生了了改变,以此为依据判断该栈是否被修改过。因为栈被修改的话,变的只能是栈顶元素,所以我们只需要通过 CAS 维护一个栈顶元素即可。

public class ConcurrentStack <E> {
    AtomicReference<Node<E>> top = new AtomicReference<Node<E>>(); // 栈顶元素

    public void push(E item) {
        Node<E> newHead = new Node<E>(item);
        Node<E> oldHead;
        do {
            oldHead = top.get();
            newHead.next = oldHead;
        } while (!top.compareAndSet(oldHead, newHead));
    }

    public E pop() {
        Node<E> oldHead;
        Node<E> newHead;
        do {
            oldHead = top.get();
            if (oldHead == null)
                return null;
            newHead = oldHead.next;
        } while (!top.compareAndSet(oldHead, newHead));
        return oldHead.item;
    }

    private static class Node <E> {
        public final E item;
        public Node<E> next;
        public Node(E item) {
            this.item = item;
        }
    }
}

非阻塞链表队列

基于2个CAS

非阻塞链表队列的实现要比栈复杂很多,因为它的插入和删除节点的操作需要修改两个指针,也就是需要2个CAS。

链表队列的插入操作需要更新以下两个指针:

  • 当前最后一个元素的 next 指针
  • 尾结点指针

在这两个操作中间,链表队列处在一种中间状态。

解决方法:

  • 可以通过检查 tail.next 是否为空来判断队列当前的状态。

    • tail.next 为空,链表队列处于稳定状态
    • tail.next 不为空,链表队列处于中间状态
  • 对于处于中间状态的链表队列,我们就进行 tail = tail.next,提前结束其他线程正在进行的插入操作,提前使队列恢复稳定状态。

public class LinkedQueue <E> {
    private static class Node <E> {
        final E item;
        final AtomicReference<LinkedQueue.Node<E>> next;
        public Node(E item, LinkedQueue.Node<E> next) {
            this.item = item;
            this.next = new AtomicReference<LinkedQueue.Node<E>>(next);
        }
    }

    private final LinkedQueue.Node<E> dummy = new LinkedQueue.Node<E>(null, null);
    private final AtomicReference<LinkedQueue.Node<E>> head
            = new AtomicReference<LinkedQueue.Node<E>>(dummy);
    private final AtomicReference<LinkedQueue.Node<E>> tail
            = new AtomicReference<LinkedQueue.Node<E>>(dummy);

    public boolean put(E item) {
        LinkedQueue.Node<E> newNode = new LinkedQueue.Node<E>(item, null);
        while (true) {
            LinkedQueue.Node<E> curTail = tail.get();
            LinkedQueue.Node<E> tailNext = curTail.next.get();
            if (curTail == tail.get()) {
                if (tailNext != null) {
                    // 队列处于中间状态,推进尾结点
                    tail.compareAndSet(curTail, tailNext);
                } else {
                    // 队列处于稳定状态,尝试插入新的节点
                    if (curTail.next.compareAndSet(null, newNode)) {
                        // 队列插入节点成功,尝试更新尾结点,注意:这时,尾结点有可能已经被其他节点更新好了
                        tail.compareAndSet(curTail, newNode);
                        return true;
                    }
                }
            }
        }
    }
}

ABA问题

问题描述: V 处的值经历了 A -> B -> A 的变化后,也认为是发生了变化的,而传统的 CAS 是无法发现这种变化的。

解决方法:

  • 使用 AtomicStampedReferenceint stamp 版本号判断数据是否被修改过
  • 使用 AtomicMarkableReferenceboolean marked 判断数据是否被修改过

AQS框架

AQS 框架是用来构建锁或其他同步组件的基础框架,其核心思想为: 如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,AQS 通过 CLH 队列实现了这种机制。 其实现原理为: 使用了一个 int 成员变量表示同步状态,然后通过内置的 FIFO 队列来完场资源获取线程的排队工作 。使用 AQS 能简单高效地构造出大量的同步器,如:

  • ReentrantLock
  • Semaphore
  • CountDownLatch
  • FutureTask
  • ReentrantReadWriteLock

CLH (Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)实现锁分配的,并且这个队列遵循 FIFO 原则。

AQS框架的方法

在构建同步器的过程中,我们主要依赖于以下几类操作:

  • 状态更改操作:

    • protected final int getState()
    • protected final void setState(int newState)
    • protected final boolean compareAndSetState(int expect, int update)
  • 获取和释放操作:

    • 独占式:

      • public final void acquire(int arg)
      • public final boolean release(int arg)
    • 共享式:

      • public final void acquireShared(int arg)
      • public final boolean releaseShared(int arg)
  • try 获取和释放操作: 模板方法,extends AbstractQueuedSynchronizer 时需要按需修改的方法。

    • 独占式:

      • protected boolean tryAcquire(int arg)
      • protected boolean tryRelease(int arg)
    • 共享式:

      • protected int tryAcquireShared(int arg)
      • protected boolean tryReleaseShared(int arg)
  • 判断同步器是否被当前线程独占:

    • protected boolean isHeldExclusively()

AQS使用方法

  • 在要构建的同步类中加一个私有静态内部类:private class Sync extends AbstractQueuedSynchronizer
  • 在子类中覆盖 AQS 的 try 前缀等方法,这样 Sync 将在执行获取和释放方法时,调用这些被子类覆盖了的 try 方法来判断某个操作是否能执行(模板方法模式,就是基于继承该类,然后根据需要重写模板方法)
  • 一个 AQS 实现简单闭锁的示例:
 public class OneShotLatch {
     private final Sync sync = new Sync();
 
     public void signal() {
         sync.releaseShared(0);
     }
 
     public void await() throws InterruptedException {
         sync.acquireSharedInterruptibly(0);
     }
 
     private class Sync extends AbstractQueuedSynchronizer {
         protected int tryAcquireShared(int ignored) {
             // Succeed if latch is open (state == 1), else fail
             return (getState() == 1) ? 1 : -1;
         }
         protected boolean tryReleaseShared(int ignored) {
             setState(1); // Latch is now open
             return true; // Other threads may now be able to acquire
         }
     }
 }