一文总结 JUC 并发编程

Posted 小毕超

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一文总结 JUC 并发编程相关的知识,希望对你有一定的参考价值。

文章目录

一、JUC 并发编程

Java 编程中,线程并发是一个十分重要的模块,而 JUC 其实就是java.util .concurrent 工具包的简称 ,该工具包整合了 Java 中对于并发处理、优化等工具。其中包括了例如线程的创建与协调、原子类、Lock 锁、线程安全集合、辅助类等。

二、协调锁

JUC 中锁可以分为两块,一个是传统的 synchronized 锁,另一个则是实现 Lock 接口的锁。

1. Synchronized

synchronizedJava 中的关键字,是一种常见的同步锁。它可以修饰一个代码块,被修饰的代码块称为同步代码块,作用的对象是调用这个代码块的对象。如果后面括号括起来的部分是一个类,其作用锁的对象是这个类的所有对象。

synchronized (this)
    ...


synchronized (A.class)
    ...

还可以修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用
的对象是调用这个方法的对象;

public synchronized void test() 
    ...

还可以修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的
所有对象;

public synchronized static void test() 
	...

注意点:虽然可以使用 synchronized 来定义方法,但 synchronized 并不属于方法定
义的一部分,因此,synchronized 关键字不能被继承。如果在父类中的某个方法使用了synchronized 关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上synchronized 关键字才可以。当然,还可以在子类方法中调用父类中相应的方法,这样虽然子类中的方法不是同步的,但子类调用了父类的同步方法,因此,子类的方法也就相当于同步了。

2. Synchronized 锁下线程通信

如果一个代码块被 synchronized 修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,其中要么获得锁的线程执行完了所有逻辑,正常释放锁,要么线程执行发生异常,由 JVM 自动释放锁,但是如果获得锁的线程需要等待某个时机,但一直不释放锁,其他线程能等待,肯定影响程序执行效率,因此线程间通信是必须的。在 Synchronized 锁的情况下,可以使用 wait()notify()notifyAll() 进行通信,其中 wait() 表示挂起当前线程并释放锁,notify()notifyAll() 则唤醒被挂起的线程,这些都是Object 内置的方法


例如实现一个生产消费的场景,消费的前提是有产品,如果没有产品则释放锁等待,生产者则生产,如果有产品则等待消费者消费后再生产:

public class Test1 
    private volatile int productNum;

    private synchronized void producer() 
        try 
            while (productNum != 0) 
                this.wait();
            
            productNum++;
            System.out.println("生产增加!");
            notifyAll();
         catch (Exception e) 
            e.printStackTrace();
        
    

    private synchronized void consumer() 
        try 
            while (productNum == 0) 
                this.wait();
            
            productNum--;
            System.out.println("消费减少!");
            notifyAll();
         catch (Exception e) 
            e.printStackTrace();
        
    

    public static void main(String[] args) 
        Test1 test1 = new Test1();
        new Thread(() -> 
            for (int i = 0; i < 5; i++) 
                test1.producer();
            
        , "线程 A").start();
        new Thread(() -> 
            for (int i = 0; i < 5; i++) 
                test1.consumer();
            
        , "线程 B").start();
    


这里补充下 Join、Wait、Sleep之间的区别:

sleep(long):在睡眠时不释放对象锁。

join(long):先执行另外的一个线程,在等待的过程中释放对象锁 底层是基于wait封装的。

wait(long):在等待的过程中释放对象锁需要在我们synchronized中使用。

3. Lock 锁

Lock 锁实现提供了比使用 Synchronized 更广泛的锁操作。它们允许更灵活的结构,可能具有不同的属性,并且可能支持多个关联的条件对象。Lock 是一个Java 接口,并非 Java 的关键字,使用 Lock 锁 需要用户手动去上锁和释放锁,如果没有主动释放锁,就有可能导致出现死锁现象。

public interface Lock 
	// 获取锁
    void lock();
    // 可中断加锁,即在锁获取过程中不处理中断状态,而是直接抛出中断异常,由上层调用者处理中断。
    void lockInterruptibly() throws InterruptedException;
    // 尝试获取锁,并立即返回结果
    boolean tryLock();
    // 尝试获取锁,在一定时间范围内
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
	// 释放锁
    void unlock();
    // 线程协调,通过它挂起、唤醒线程
    Condition newCondition();

注意:如果采用 Lock锁,必须主动去释放锁,并且在发生异常的情况下,是不会自动释放锁。所以使用 Lock 时,建议在 try 块中进行,并且在 finally 块中释放锁,以保证锁一定会被释放,防止死锁 。

其中在 Lock 接口中,提供了 newCondition() 方法获取一个 Condition 接口对象,该对象提供了 await()signal() 方法,与 synchronizedwait()notify() 方法类似,可以挂起、唤醒线程,不过 Condition 可以声明出多个,以分别应对不同的线程,进行针对性的挂起和唤醒。

Lock 对比 synchronized 在性能上来说,如果竞争资源不激烈,两者的性能差不多,当竞争资源非常激烈时, Lock 的性能要远远优于 synchronized

4. Lock 锁实例 - ReentrantLock

Synchronized 相同 ReentrantLock 为可重入锁,可通过 fair 参数决定当前是公平锁还是非公平锁,例如生产消费的场景实现:

public class Test1 
    //可重入锁
    private Lock lock = new ReentrantLock(true);
    // 生产者的选择器
    private Condition producerCondition = lock.newCondition();
    // 消费者的选择器
    private Condition consumerCondition = lock.newCondition();

    private volatile int productNum;

    private void producer() 
        lock.lock();
        try 
            while (productNum != 0) 
                producerCondition.await();
            
            productNum++;
            System.out.println("生产增加!");
            // 唤起消费者
            consumerCondition.signalAll();
         catch (Exception e) 
            e.printStackTrace();
         finally 
            lock.unlock();
        
    

    private void consumer() 
        lock.lock();
        try 
            while (productNum == 0) 
                consumerCondition.await();
            
            productNum--;
            System.out.println("消费减少!");
            // 唤起生产者
            producerCondition.signalAll();
         catch (Exception e) 
            e.printStackTrace();
         finally 
            lock.unlock();
        
    

    public static void main(String[] args) 
        Test1 test1 = new Test1();
        new Thread(() -> 
            for (int i = 0; i < 5; i++) 
                test1.producer();
            
        , "线程 A").start();
        new Thread(() -> 
            for (int i = 0; i < 5; i++) 
                test1.consumer();
            
        , "线程 B").start();
    

5. 读写锁 - ReadWriteLock

读写锁将操作读写分开成 2 个锁来分配给线程,在多个线程读的情况下,不受锁的控制,但是在写的情况下,只允许一个获得锁的线程来写,并且写的过程中读锁也在等待。

例如:

public class Test 
    private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private Lock readLock = readWriteLock.readLock();
    private Lock writeLock = readWriteLock.writeLock();

    public void read() 
        readLock.lock();
        try 
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getName() + "读操作,进行中!");
         catch (Exception e) 
            e.printStackTrace();
         finally 
            readLock.unlock();
        
    

    public void write() 
        writeLock.lock();
        try 
            System.out.println(Thread.currentThread().getName() + "写操作,进行中!");
            Thread.sleep(1000);
         catch (Exception e) 
            e.printStackTrace();
         finally 
            writeLock.unlock();
        
    

    public static void main(String[] args) 
        Test test = new Test();
        new Thread(() -> 
            for (int i = 0; i < 5; i++) 
                test.read();
            
        , "线程 A").start();
        new Thread(() -> 
            for (int i = 0; i < 5; i++) 
                test.read();
            
        , "线程 B").start();
        new Thread(() -> 
            for (int i = 0; i < 5; i++) 
                test.write();
            
        , "线程 C").start();
    


可以看出,当写锁执行时,读锁也会等待,写锁释放后,读锁不会阻塞。

三、CAS & 原子类

上面情况下的锁都属于悲观锁,当多个线程对争夺同一个锁时,最后只有一个线程才能获取成功,其余线程只能是阻塞等待,比如:mysql中在更新一条数据时,事务未提交时,通过 for update 查询最新的数据,只能等待事务提交后方可查询。

而乐观锁比较乐观,通过预值或者版本号比较,进行 CAS ,如果不一致性的情况则通过循环控制修改,当前线程不会被阻塞,数据也没有加锁,因此性能比悲观锁要好。

比如下面的 SQL 语句:

update table set value='newValue', version=version+1 where id = 1 and version = 1;  

1. CAS

Compare and Swap 的缩写,译为比较并交换,其中有三个主要的参数 CAS(V,E, N),内存值V,旧的预期值E,要修改的新值N, 在将值由 V 更新为 N 时,先进行 E == V 判断,如果成立则更新 V 的值为 N ,否则什么都不做。一般通过自旋操作控制不成立的情况,则重新计算 N 值,并将当前的 V 值赋予 E 值 ,重新判断。

但这种情况有可能会遇到 ABA 的问题,就是原来的值为 A,此时有线程改为了 BB 又改为了A ,此时如果某个线程中的旧值还是 A,会感知不到中间发生的变化,解决该问题,可以通过增加版本号,每次修改对版本号加一,后面进行版本号的对比。

CAS 也不是没有缺点,如果长时间不成功,一直处于自旋状态,会给CPU带来非常大的执行开销。而悲观锁就不会给 CPU 带来很大的开销,但会带来比较多的上下文切换。

CAS 的过程需要保证原子性,在 Java 中通过 unsafe jni 技术使用硬件指令来实现。

Java 中通过该技术实现了一批类,可以让我们无需关注底层的CAS过程。

2. 原子类

原子类是 java.util.concurrent.atomic 包下的类,在多线程情况下通过 CAS 无锁机制进行数据的更新。

2.1 基础原子类

原子类中提供了基础类型的类:AtomicInteger、AtomicBoolean、AtomicLong,可以直接开箱即用,以 AtomicInteger 为例,其中常用的 API 如下:

// 获取当前值。
public final int get() 
// 获取当前值,并将当前值设置为新值
public final int getAndSet(int newValue) 
// 获取当前值,并将当前值 +1 
public final int getAndIncrement() 
// 将当前值 +1 ,获取当前值
public final int incrementAndGet()
// 获取当前值,并将当前值 -1
public final int getAndDecrement()
 // 将当前值 -1 ,获取当前值
public final int decrementAndGet()
// 获取当前值,并将当前值 + delta
public final int getAndAdd(int delta) 
// 将当前值 + delta , 获取当前值
public final int addAndGet(int delta)
// 如果当前值==为预期值,则将该值设置为给定的新值。
public boolean comapreAndSet(int expect,int update) 

例如当多个线程对一个变量进行操作时,会出现线程安全问题,现在换成原子类,进行测试下,当两个线程对一个变量进行 +1

public class ATest1 

    public static void main(String[] args) 
        AtomicInteger productNum = new AtomicInteger(0);
        new Thread(() -> 
            for (int i = 0; i < 5; i++) 
                System.out.println(productNum.incrementAndGet());
            
        , "线程 A").start();
        new Thread(() -> 
            for (int i = 0; i < 5; i++) 
                System.out.println(productNum.incrementAndGet());
            
        , "线程 B").start();
    


2.2 数组类型原子类

原子类中数组类型的原子类有:AtomicIntegerArray、AtomicLongArray、AtomicRreferenceArray,相比于基础类型,在数据操作时,需要传入数据的下标。

AtomicIntegerArray atomicIntegerArray = new AtomicIntegerArray(10);
// 第 0 个位置的数据,+ 10
atomicIntegerArray.getAndAdd(0,10);

2.3 引用型原子类

上面的类型都是指定类型的, 在实际开发中会遇到各种各样的类型,为此为我们提供了引用型原子类,AtomicReference<V> ,例如实现一个 String 类型的原子类:

AtomicReference<String> atomicReference = new AtomicReference<>("");
//设置数据
atomicReference.set("abc");
//获取数据
System.out.println(atomicReference.get());

四、线程

创建线程上面使用了,直接创建 Thread 类的方式,在刚开始学习线程时,还有一种通过使用 Runnable 创建线程,这些基本的方式这里就不做介绍了,这里主要介绍两个,Callable & Future、线程池。

1. Callable 和 Future

上面创建线程的方式都拿不到线程返回的结果,为了支持此功能,Java 中提供了 Callable 接口 和 Future 接口。

对于 Callable,需要通过 call()方法返回线程的结果,如:

Callable<String> callable = () -> 
    System.out.println(Thread.currentThread().getName() + " 执行了");
    return "success";
;

不过 call() 返回的结果需要存储在主线程已知的对象中,以便主线程可以知道该线程返回的结果。此时就需要用到 Future 对象,Future 可以视为保存结果的对象,它可能暂时不保存结果,但将来会保存(一旦Callable 返回),Future 基本上是主线程可以跟踪进度以及其他线程的结果的一种方式。

public class Test2 
    public static void main(String[] args) throws Exception 
        Callable<String> callable = () -> 
            System.out.println(Thread.currentThread().getName() + " 执行了");
            return "success";
        ;
        FutureTask<String> future = new FutureTask<>(callable);
        new Thread(future<

以上是关于一文总结 JUC 并发编程的主要内容,如果未能解决你的问题,请参考以下文章

Java知识点JUC总结

并发编程总结——java线程基础1

Java并发编程系列之三JUC概述

Java基础学习总结(193)—— JUC 常用并发工具类总结

JUC并发编程 -- JUC介绍 & 线程/进程 & 并发/并行 & Java代码查看CPU的核数

Java并发编程 JUC中的锁