JUC包常用类原理

放眼望去,java.util.concurrent包下类大致包括:atomic 原子类、锁、并发集合、线程池、工具类。我们挑重要的了解一下。

Atomic 原子类

Java针对并发编程已经有了各种锁,为什么还需要原子类?原子类一定有些特别的应用场景?

在很多时候,我们需要的仅仅是一个简单的、高效的、线程安全的递增或者递减方案,这个方案一般需要满足以下要求:

1、 简单:操作简单,底层实现简单

2、 高效:占用资源少,操作速度快

3、 安全:在高并发和多线程环境下要保证数据的正确性

对于是需要简单的递增或者递减的需求场景,使用 synchronized 关键字和 lock 固然可以实现,但代码写的会略显冗余,且性能会有影响,此时用原子类更加方便。

Atomic 类的原理

通过 CAS 实现线程安全访问。CAS 可认为是无阻塞的,一个线程的失败或挂起不会引起其它线程也失败或挂起。但并发量很大的话,CPU 会花费大量的时间在试错上面。如果并发量小的情况,这些消耗可以忽略不计。因此 Atomic 类会因为并发度太高而性能变差。

AtomicInteger

在Java中,++ii++操作并不是线程安全的,因为他们不是原子操作。在并发场景下,数值加减操作有线程不安全,而 synchronized 这种锁又大大降低了并发效率。这时候就可以考虑使用AtomicInteger。它是一个提供原子操作的Integer的类。

我们先来看看AtomicInteger给我们提供了什么接口:

int getAndIncrement(); // 获取当前值,然后自加,相当于i++
int getAndDecrement(); // 获取当前值,然后自减,相当于i--
int incrementAndGet(); // 自加1后并返回,相当于++i
int decrementAndGet(); // 自减1后并返回,相当于--i
int getAndAdd(int delta); // 获取当前值,并加上预期值
int getAndSet(int newValue); // 获取当前值,并设置新值

AtomicLong

AtomicLong也是在高并发下对单一变量进行 CAS 操作,从而保证其原子性。

LongAdder

LongAdder类继承了Striped64类,LongAdder是一种以空间换时间的解决方案。其内部维护了一个long类型的base变量,和一个cell数组,当线程写base有冲突时,将其写入数组的一个cell中。将base和所有cell中的值求和就得到最终LongAdder的值了。

  1. 在高并发的场景, LongAdderAtomicLong更高效。代价是更高的空间消耗。
  2. 在并发度不高的情况下,LongAdderAtomicLong性能差不多。

LongAdder可以作为数据库主键生成器。

Atomic 类总结

并发度不高用 AtomicIntegerAtomicLong,并发度高用LongAdder

AQS

AbstractQueuedSynchronizer(AQS)提供了一套可用于实现锁同步机制的框架。AQS通过一个FIFO队列维护线程同步状态,实现类只需要继承该类,并重写指定方法即可实现一套线程同步机制。AQS根据资源互斥级别提供了独占和共享两种资源访问模式;同时其定义Condition结构提供了wait/signal等待唤醒机制。在JUC中,诸如ReentrantLockCountDownLatch等都基于AQS实现。

ReentrantLock

ReentrantLocksynchronized的作用是相同的,它们的比较:

  1. 它们都是可重入锁。synchronized是Java语言层面提供的语法,不需要考虑异常,且会自动释放锁,而ReentrantLock是Java代码实现的锁,必须先获取锁,然后在finally中正确释放锁。
  2. ReentrantLock可以尝试获取锁,超时还获取不到锁就可以处理别的事情,如tryLock(long timeout, TimeUnit unit);而synchronized不能,只能一直等待。所以,使用ReentrantLock比直接使用synchronized更安全,线程在tryLock()失败的时候不会导致死锁。
  3. 可中断性:synchronized 锁是不可中断的,无法响应中断请求。ReentrantLock支持中断,可以响应中断请求。
  4. 锁的公平性:synchronized关键字是非公平锁,即不保证等待线程获取锁的先后顺序。ReentrantLock可以实现公平锁和非公平锁,默认是非公平锁,但可以通过构造方法来实现公平锁。公平锁:公平锁,多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁。

ReentrantLock 使用 Condition

synchronized可以配合waitnotify实现线程在条件不满足时等待,条件满足时唤醒,用ReentrantLock我们怎么编写waitnotify的功能呢?

class TaskQueue {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    private Queue<String> queue = new LinkedList<>();

    public void addTask(String s) {
        lock.lock();
        try {
            queue.add(s);
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public String getTask() {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                condition.await();
            }
            return queue.remove();
        } finally {
            lock.unlock();
        }
    }
}

可见,使用Condition时,引用的Condition对象必须从Lock实例的newCondition()返回,这样才能获得一个绑定了Lock实例的Condition实例。

Condition提供的await()signal()signalAll()原理和synchronized锁对象的wait()notify()notifyAll()是一致的,并且其行为也是一样的:

  • await()会释放当前锁,进入等待状态;
  • signal()会唤醒某个等待线程;
  • signalAll()会唤醒所有等待线程;
  • 唤醒线程从await()返回后需要重新获得锁。

ReadWriteLock

因为synchronizedReentrantLock这两种锁都是独占锁,每次只允许一个线程执行临界区代码,所以它们是比较重量级的锁。有些读多写少的场景,只用保证写的时候只有一个线程,而读的时候可以多个线程同时读。此时读写锁能派上用场了。

ReadWriteLock的特点:

  • 只允许一个线程写入(其他线程既不能写入也不能读取);
  • 没有写入时,多个线程允许同时读(提高性能)

ReadWriteLock大大提高了并发读的执行效率。

注意:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。

使用举例:

public class Counter {
    private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
    private final Lock rlock = rwlock.readLock();
    private final Lock wlock = rwlock.writeLock();
    private int[] counts = new int[10];

    public void inc(int index) {
        wlock.lock(); // 加写锁
        try {
            counts[index] += 1;
        } finally {
            wlock.unlock(); // 释放写锁
        }
    }

    public int[] get() {
        rlock.lock(); // 加读锁
        try {
            return Arrays.copyOf(counts, counts.length);
        } finally {
            rlock.unlock(); // 释放读锁
        }
    }
}

并发集合

👉 集合知识点梳理

线程池

ThreadPoolExecutor

👉 线程池原理

ForkJoin

Fork/Join是一种基于“分治”的算法:把大任务分解成小任务,并行执行,最后合并结果得到最终结果。

如何使用

使用ForkJoinPool来进行并行计算,主要分为两步:

  1. 定义一个ForkJoinTask,一般是继承它的子类: RecursiveTaskRecursiveAction ,然后重写compute()方法,定义拆分逻辑和计算逻辑RecursiveTask有返回值,RecursiveAction 没有返回值。
  2. 初始化线程池及计算任务,丢入线程池处理,取得处理结果。

ForkJoinTask

ForkJoinTask实现了Future,跟FutureTask类似。它有两个常用方法:

  1. fork():把任务推入当前工作线程的工作队列里。
  2. join():等待处理任务的线程处理完毕,获得返回值。

并不是所有的任务都适合 Fork/Join 框架,比如上面的例子任务划分过于细小反而体现不出效率。因为Fork/Join是使用多个线程协作来计算的,所以会有线程通信和线程切换的开销。

例子1:计算斐波那契数列前n个数的和,如果设f(n)为该数列的第n项(n∈N*),那么有:f(n) = f(n-1) + f(n-2)。


public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ForkJoinPool forkJoinPool = new ForkJoinPool();

        System.out.println("CPU核数:" + Runtime.getRuntime().availableProcessors());
        long start = System.currentTimeMillis();

        Fibonacci fibonacci = new Fibonacci(40);
        Future<Integer> future = forkJoinPool.submit(fibonacci);
        System.out.println(future.get());
        long end = System.currentTimeMillis();
        System.out.printf("耗时:%d ms%n", end - start);
    }
}

class Fibonacci extends RecursiveTask<Integer> {

    int n;

    public Fibonacci(int n) {
        this.n = n;
    }

    @Override
    protected Integer compute() {
        // 这里先假设 n >= 0
        if (n <= 1) {
            return n;
        } else {
            Fibonacci f1 = new Fibonacci(n - 1);  // f(n-1)
            f1.fork();
            Fibonacci f2 = new Fibonacci(n - 2); // f(n-2)
            f2.fork();
            return f1.join() + f2.join(); // f(n) = f(n-1) + f(n-2)
        }
    }
}

例子2:计算一个长度为2000的随机数组的元素的和。

public class Main {
    static Random random = new Random(0);

    static long random() {
        return random.nextInt(10000);
    }

    public static void main(String[] args) throws Exception {
        // 创建2000个随机数组成的数组:
        long[] array = new long[2000];
        long expectedSum = 0;
        for (int i = 0; i < array.length; i++) {
            array[i] = random();
            expectedSum += array[i];
        }
        System.out.println("Expected sum: " + expectedSum);
        // fork/join:
        ForkJoinTask<Long> task = new SumTask(array, 0, array.length);
        long startTime = System.currentTimeMillis();
        Long result = ForkJoinPool.commonPool().invoke(task);
        long endTime = System.currentTimeMillis();
        System.out.println("Fork/join sum: " + result + " in " + (endTime - startTime) + " ms.");
    }
}

class SumTask extends RecursiveTask<Long> {
    static final int THRESHOLD = 500;
    long[] array;
    int start;
    int end;

    SumTask(long[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }

    @Override
    protected Long compute() {
        if (end - start <= THRESHOLD) {
            // 如果任务足够小,直接计算:
            long sum = 0;
            for (int i = start; i < end; i++) {
                sum += this.array[i];
                // 故意放慢计算速度:
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                }
            }
            return sum;
        }
        // 任务太大,一分为二:
        int middle = (end + start) / 2;
        System.out.println("split %d~%d ==> %d~%d, %d~%d", start, end, start, middle, middle, end);
        SumTask subtask1 = new SumTask(this.array, start, middle);
        SumTask subtask2 = new SumTask(this.array, middle, end);
        ForkJoinTask.invokeAll(subtask1, subtask2);
        Long subresult1 = subtask1.join();
        Long subresult2 = subtask2.join();
        Long result = subresult1 + subresult2;
        System.out.println("result = " + subresult1 + " + " + subresult2 + " ==> " + result);
        return result;
    }
}

工具类

Semaphore

Semaphore本质上就是一个信号计数器,用于限制同一时间的最大访问数量。例如,最多允许3个线程同时访问:

public class AccessLimitControl {
    // 任意时刻仅允许最多3个线程获取许可:
    final Semaphore semaphore = new Semaphore(3);

    public String access() throws Exception {
        // 如果超过了许可数量,其他线程将在此等待:
        semaphore.acquire();
        try {
            // TODO:
            return UUID.randomUUID().toString();
        } finally {
            semaphore.release();
        }
    }
}

使用Semaphore先调用acquire()获取,然后通过try ... finally保证在finally中释放。

使用场景:如果要对某一受限资源进行限流访问,可以使用Semaphore,保证同一时间最多N个线程访问受限资源。

参考资料

廖雪峰的官方网站

posted @ 2023-07-11 18:12  xfcoding  阅读(48)  评论(0编辑  收藏  举报