【JUC高并发编程】—— 初见JUC

一、JUC 概述

什么是JUC

JUCJava并发编程的缩写,指的是 Java.util.concurrent 即Java工具集下的并发编程库 【说白了就是处理线程的工具包】

JUC提供了一套并发编程工具,这些工具是Java 5以后引入的,使得Java开发者可以更加方便地编写高效的并发程序

【JUC高并发编程】—— 初见JUC_第1张图片

JUC包含许多有用的类和接口,如线程池、阻塞队列、同步器、原子变量、并发集合等,它们能够帮助Java开发者编写更加高效和可靠的并发程序

使用JUC可以充分发挥多核CPU的并发优势,提高程序的响应速度和吞吐量,从而提升应用程序的性能和用户体验。

JUC的相关类和接口非常多,使用时需要根据实际需求进行选择和组合,同时需要注意其复杂性和易出现的并发问题,如死锁、竞态条件等。因此,在使用JUC进行并发编程时需要具有一定的经验和技能

进程和线程的概念

1️⃣ 进程与线程

(1)什么是进程?

进程(Process) 是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础

  • 在当代面向线程设计的计算机结构中,进程是线程的容器。
  • 程序是指令、数据及其组织形式的描述,进程是程序的实体。
  • 是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
  • 程序是指令、数据及其组织形式的描述,进程是程序的实体。

(2)什么是线程?

线程(thread) 是操作系统能够进行运算调度的最小单位

  • 它被包含在进程之中,是进程中的实际运作单位
  • 一条线程指的是进程中一个单一顺序的控制流, 一个进程中可以并发多个线程,每条线程并行执行不同的任务

总结来说:

  • 进程:指在系统中正在运行的一个应用程序;程序一旦运行就是进程 【资源分配的最小单位】
  • 线程:系统分配处理器时间资源的基本单元,或者说进程之内独立执行的一个单元执行流 【程序执行的最小单位】

2️⃣ 线程的状态

在操作系统层面,任何进程都有五种状态

  • 新建状态:新创建了一个线程对象,但是还没有开始执行,此时线程处于新建状态。

  • 就绪状态:线程创建后,通过start()方法启动线程,此时线程进入线程调度器的就绪队列中,等待调度器的调度。

  • 运行状态:线程调度器从就绪队列中选取一个线程,并将其转移到运行态,开始执行run()方法中的代码。

  • 阻塞状态:线程正在执行run()方法中的代码,但被某些原因阻塞了,如等待某个资源、睡眠(Thread.sleep())、等待输入输出完成(IO)等。

  • 死亡状态:线程运行完了run()方法中的代码,或者调用了stop()方法,导致线程结束。

【JUC高并发编程】—— 初见JUC_第2张图片

补充: 线程的状态是由操作系统控制的,Java程序只能感知线程状态的改变但无法直接控制。

在Java的线程生命周期中有六种状态

  • 新建状态(New):新创建了一个线程对象,但还没有调用 start() 方法,此时线程处于新建状态
  • 运行状态(Runnable):线程调用 start() 方法之后,进入线程调度器的可执行队列中,等待 CPU 执行线程的 run() 方法
  • 阻塞状态(Blocked):线程因为某些原因被阻塞,比如等待 IO 操作或者获取 synchronized 锁,此时线程进入阻塞状态
  • 等待状态(Waiting):线程因为某些条件需要等待,可以通过调用 Object.wait() 方法或者 Thread.join() 方法进入等待状态
  • 计时等待状态(Timed Waiting):线程在等待一段时间后会返回,可以通过调用 Thread.sleep() 方法或者 Object.wait(long) 方法使线程进入计时等待状态
  • 终止状态(Terminated):线程执行完了 run() 方法或者调用了 Thread.stop() 方法导致线程终止,此时线程进入终止状态

需要注意的是,阻塞状态、等待状态和计时等待状态都属于非可执行状态,因为这些状态下线程无法直接执行其 run() 方法,只有当条件满足之后才能进入运行状态。

3️⃣ wait 和 sleep 有什么区别:

  • sleepThread 的静态方法;waitObject 的方法,任何对象实例都能调用。
  • sleep 不会释放锁,它也不需要占用锁;wait 会释放锁,但调用它的前提是当前线程占有锁(即代码要在 synchronized 中)
  • 它们都可以被 interrupted 方法中断

4️⃣ 并发和并行

首先我们需要了解什么是串行模式、什么是并行模式?

(1)串行模式

串行表示所有任务都按先后顺序进行
串行意味着必须先装完一车柴才能运送这车柴,只有运送到了,才能卸下这车柴,并且只有完成了这整个三个步骤,才能进行下一个步骤
串行是一次只能取得一个任务,并执行这个任务

(2)并行模式

并行意味着可以同时取得多个任务,并同时去执行所取得的这些任务
并行模式相当于将长长的一条队列,划分成了多条短队列,所以并行缩短了任务队列的长度
并行的效率从代码层次上强依赖于多进程/多线程代码,从硬件角度上则依赖于多核 CPU

那么什么又是并发呢?

并发(concurrent) 是指在单个处理器上同时处理多个任务的能力,通过CPU时间片轮转的调度方式来实现多个任务交替执行的效果,使得它们在同一个时间段内都获得了执行的机会

由于时间片的切换速度非常快,所以对于用户来说,多个任务看起来像是同时执行的

那么并行和并发相比有什么区别呢?

并行则是指同时使用多个处理器或多核处理器来处理多个不同的任务,每个处理器或核心处理一部分任务,各自互不干扰,最终集中处理器或核心的结果形成总体处理结果

并行通常比并发更具有性能优势,因为它可以利用CPU的多个核心同时进行计算。

综上所述,虽然并发和并行都是指多任务同时执行的情况,但是:

  • 并发是在单个处理器上轮流处理多个任务
  • 而并行则是在多个处理器或者多核处理器上同时处理多个不同的任务
  • 因此它们在适用场景和实现方式上有着显著的不同

5️⃣ 管程

什么是管程?

管程(Moniotor)是一个并发编程中用于实现互斥和同步的一种工具或模式,它是由荷兰计算机科学家 Edsger W. Dijkstra 首次提出的。主要用于解决共享资源的互斥问题,简化多线程之间的同步操作

通俗地说,管程是一个数据结构和一组操作共同组成的一个抽象,它可以包含一个或多个共享变量以及对这些变量的操作。通过对管程的操作,保证了同一时刻只能有一个线程访问共享变量,从而避免了竞争条件和死锁等并发问题。

管程的基本操作通常包括进入管程(monitor enter)、退出管程(monitor exit)以及条件变量(condition variable) 等。当一个线程进入管程时,它会获得管程的监视权,只有当它退出管程或者调用等待操作时才会放弃监视权。而条件变量则是用于在管程中对线程进行等待和唤醒操作的一种机制。

管程的优点

  • 封装了共享变量以及对这些变量的访问操作,隐藏了实现细节,从而减少了并发编程的错误和复杂性。
  • 同时,管程的操作是原子的,可以避免竞争条件和死锁等问题。

补充:

JVM 中同步是基于进入和退出管程(monitor)对象实现的,每个对象都会有一个管程(monitor)对象,管程(monitor)会随着java 对象一同创建和销毁

【JUC高并发编程】—— 初见JUC_第3张图片

执行线程首先要持有管程对象,然后才能执行方法,当方法完成之后会释放管程,方法在执行时候会持有管程,其他线程无法再获取同一个管程

6️⃣ 用户线程和守护线程

基本概念:

  • 用户线程: 平时用到的普通线程,自定义线程
  • 守护线程: 运行在后台,是一种特殊的线程,比如垃圾回收

案例演示:

(1)当我们主线程结束,用户线程没有结束,那么JVM还会继续进行下去 【当主线程结束后,用户线程还在运行,JVM 存活】

package com.atguigu.part1;

/**
 * @author Bonbons
 * @version 1.0
 * 演示普通线程和守护线程的区别
 */
public class DaemonThread {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            //获取当前线程
            String s = Thread.currentThread().getName() + " ::" + Thread.currentThread().isDaemon();
            System.out.println(s);

            //设置永真while循环
            while(true){

            }
        }, "t1");
        t1.start();
        System.out.println(Thread.currentThread().getName() + " over");
    }
}

【JUC高并发编程】—— 初见JUC_第4张图片
(2)我们将用户线程设置为守护线程,当JVM里面没有了运行用户线程,那么守护线程自动结束 【如果没有用户线程,都是守护线程,JVM 结束】

package com.atguigu.part1;

/**
 * @author Bonbons
 * @version 1.0
 * 演示普通线程和守护线程的区别
 */
public class DaemonThread {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            //获取当前线程
            String s = Thread.currentThread().getName() + " ::" + Thread.currentThread().isDaemon();
            System.out.println(s);

            //设置永真while循环
            while(true){

            }
        }, "t1");
        //将t1线程设置为守护线程 [在线程启动之前设置]
        t1.setDaemon(true);
        t1.start();
        System.out.println(Thread.currentThread().getName() + " over");
    }
}

【JUC高并发编程】—— 初见JUC_第5张图片


二、Lock 接口

复习Synchronized

1️⃣ Synchronized作用范围

修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{} 括起来的代码,作用的对象是调用这个代码块的对象

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

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

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

修饰一个类,其作用的范围是 synchronized 后面括号括起来的部分,作用的对象是这个类的所有对象

2️⃣ Synchronized实现卖票例子

多线程编程的步骤(上):

  • 第一 创建资源类,创建属性和操作方法
  • 第二 创建多线程调用资源类的方法

通过代码演示如何实现基础版的卖票案例

package com.atguigu.sync;

/**
 * @author Bonbons
 * @version 1.0
 * 使用Synchronized实现卖票,30张票、3个售票员
 */
public class SellTicket {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        new Thread(() -> {
            for (int i = 0; i < 40; i++) {
                ticket.sell();
            }
        }, "t1").start();

        new Thread(() -> {
            for (int i = 0; i < 40; i++) {
                ticket.sell();
            }
        }, "t2").start();

        new Thread(() -> {
            for (int i = 0; i < 40; i++) {
                ticket.sell();
            }
        }, "t3").start();
    }
}
//定义资源类、属性、操作方法
class Ticket{
    private int ticket_num = 30;
    public synchronized void sell(){
        if(ticket_num <= 0){
            return;
        }
        System.out.println(Thread.currentThread().getName() + "卖了: " + ticket_num + ";剩余: " + (--ticket_num));
    }
}

【JUC高并发编程】—— 初见JUC_第6张图片

3️⃣ 总结:

如果一个代码块被 synchronized 修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:

1)获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
2)线程执行发生异常,此时 JVM 会让线程自动释放锁。

那么如果这个获取锁的线程由于要等待 IO 或者其他原因(比如调用 sleep 方法)被阻塞了,但是又没有释放锁,其他线程便只能干巴巴地等待,试想一下,这多么影响程序执行效率。

因此就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断),通过 Lock 就可以办到

什么是Lock接口

1️⃣ Lock接口介绍

Lock 锁实现提供了比使用同步方法和语句可以获得的更广泛的锁操作。它们允许更灵活的结构,可能具有非常不同的属性,并且可能支持多个关联的条件对象。Lock 提供了比 synchronized 更多的功能。

Lock 与的 Synchronized 区别

  • Lock 不是 Java 语言内置的,synchronizedJava 语言的关键字,因此是内置特性。Lock 是一个类,通过这个类可以实现同步访问
  • Locksynchronized 有一点非常大的不同
    • 采用 synchronized 不需要用户去手动释放锁,当 synchronized 方法或者 synchronized 代码块执行完之后, 系统会自动让线程释放对锁的占用
    • 而 Lock 则必须要用户去手动释放锁,如果没有主动释放锁,就有可能导致出现死锁现象。

创建线程的多种方式

1️⃣ 继承Thread类

创建一个线程时,可以定义一个新的类,继承自 Thread 类,并重写 run() 方法。在新定义的类中,可以调用 start() 方法来启动线程

class MyThread extends Thread{
	@Override
	public void run(){
		System.out.println("This is a new Thread!");
	}
}
MyThread mt = new MyThread();
mt.start();

2️⃣ 实现Runnable接口

  • 可以定义一个新的类实现 Runnable 接口,并重写 run() 方法
  • 然后创建一个 Thread 对象,将该 Runnable 对象作为参数传入,并调用 start() 方法启动线程
class MyRunnable implements Runnable{
	@Override
	public void run(){
		System.out.println("This is a new Thread!");
	}
}
MyRunnable mr = new MyRunnable();
Thread t = new Thread(mr);
t.start();

3️⃣ 使用Callable接口

  • 可以创建一个 Callable 对象,实现它的 call() 方法,并使用 ExecutoService 提供的 submit() 方法来执行它
  • submit() 方法返回一个 Future 对象,通过该对象可以获取 Callable 的执行结果
class MyCallable implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
         //...
         return result;
    }
}
// 使用 ExecutorService 提交 Callable 任务
ExecutorService executor = Executors.newFixedThreadPool(1);
Future<Integer> future = executor.submit(new MyCallable());
// 获取 Callable 的执行结果
System.out.println(future.get());

4️⃣ 使用线程池

  • 线程池可以重复使用已经创建的线程,从而避免在需要处理大量请求时频繁创建和销毁线程的开销
  • Java提供了 ThreadPoolExecutor 类来实现线程池
ExecutorService executor = Executors.newFixedThreadPool(10);  // 创建一个线程池
executor.execute(new Runnable() {
    public void run() {
         System.out.println("This is a new thread.");
    }
});

使用Lock实现卖票例子

package com.atguigu.lock;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @author Bonbons
 * @version 1.0
 * 使用Lock锁实现售票
 */
public class SaleTicket {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();

        new Thread(() -> {
            for(int i = 1; i <= 20; i++){
                ticket.sale();
            }
        }, "AA").start();

        new Thread(() -> {
            for(int i = 1; i <= 20; i++){
                ticket.sale();
            }
        }, "BB").start();

        new Thread(() -> {
            for(int i = 1; i <= 20; i++){
                ticket.sale();
            }
        }, "CC").start();
    }
}

//资源类
class Ticket{
    private int num = 30;
    //创建Lock锁的对象
    private final ReentrantLock lock = new ReentrantLock();

    public void sale(){
        //上锁
        lock.lock();
        try{
            //判断是否有余票
            if(num > 0){
                //打印
                System.out.println(Thread.currentThread().getName() + "售票: " + (num--) +
                        " ;剩余: " + (num));
            }
        }finally {
            //释放
            lock.unlock();
        }
    }
}

【JUC高并发编程】—— 初见JUC_第7张图片
上面采用的是Lock接口的一个实现类, ReentrantLock(可重入锁)实现的

  • 那么什么是可重入锁呢?
    • 可重入锁(Reentrant Lock)是一种线程同步机制,它允许一个线程重复地获得同一个锁,从而避免了死锁的发生。可重入锁最先是在 JDK 1.5 中引入的
    • 在使用可重入锁时,同一个线程可以多次获取同一个锁,不会造成死锁或者无法获取锁的情况
    • 可重入锁通常需要使用一个计数器来记录当前线程获取该锁的次数,每获取一次计数器加一,每释放一次计数器减一
    • 只有当计数器为零时,锁才被完全释放

Lock 接口实现类介绍

  • Lock接口是Java中提供的一个基本的线程同步工具,用于协调多个线程的共享资源访问
  • 它的主要作用是保证同一时间只有一个线程可以访问临界区(Critical Section)(一个被共享的资源或一段代码)
  • 从而避免了多线程访问共享资源时可能产生的数据竞争、死锁等问题

1️⃣ 先看一下 Lock 接口包含哪些内容:

public interface Lock { 
	//获得锁,如果锁已被占用,则等待直到获取到锁
	void lock();
	//获得锁,但如果当前线程被中断,则抛出InterruptedException异常
	void lockInterruptibly() throws InterruptedException; 
	//尝试获得锁,如果锁未被占用,则获取到锁并返回true,否则立即返回false,不会等待
	boolean tryLock();
	//尝试获得锁,在指定的时间内如果未获得锁,则返回false,否则返回true
	boolean tryLock(long time, TimeUnit unit) throws InterruptedException; 
	//释放锁,如果当前线程持有该锁,则释放,并通知等待的线程
	void unlock();
	//返回一个与当前 Lock 绑定的 Condition 对象,可以用于唤醒等待在该 Condition 上的线程
	Condition newCondition();
}

2️⃣ Lock 接口的实现类介绍:

  • ReentrantLock:【允许一个线程重复获得同一个锁】
    • 可重入锁,它是Lock接口的主要实现类,使用了独占模式(在一段时间内只允许一个线程访问共享资源)
    • ReentrantLock提供了一些高级功能,比如公平/非公平锁、可中断的锁和限时等待锁等
  • ReentrantReadWriteLock
    • 可重入读写锁,是读写分离锁的一种实现,是一种特殊的可重入锁
    • 它允许多个线程同时访问共享资源,但是对于写操作是互斥的,读操作可以共享。
    • ReentrantReadWriteLock提供了读锁和写锁两种模式。
  • StampedLock
    • 乐观读写锁,支持三种模式:写模式、读模式和乐观读模式
    • 乐观读模式下,线程无需获取锁即可读取数据,当发现数据被其他线程更改后,可以立刻升级成读锁或写锁
  • ReadWriteLock
    • 读写锁,与ReentrantReadWriteLock类似,但与之不同的是,它是接口而非具体实现
    • ReadWriteLock提供了读锁和写锁两种模式,使用时需要自己实现。

除了上述常见的Lock实现类外,还有一些其他的实现类,例如:

  • FairLock
    • 公平锁,实现了Lock接口,并使用了公平锁算法,在多线程环境下,保证了线程的公平性,避免了线程饥饿的问题
  • SpinLock
    • 自旋锁,它是一种不会让线程真正被挂起的锁,当某个线程请求锁时,如果锁被其他线程占用,线程会一直忙等待(自旋),直到锁被释放
  • CLH锁:
    • 一种基于链表的可扩展、高性能自旋锁,CLH锁可以动态添加多个等待线程,但是它需要支持硬件的CMPXCHG指令

总的来说,在选择使用Java提供的不同Lock实现类时,需要根据具体的应用场景和需求,选择不同的实现方式和策略,以达到最优的多线程同步效果

总结 Lock 与 Synchronized 的区别

  • Lock 是一个接口,而 synchronized 是 Java 中的关键字,synchronized 是内置的语言实现
  • synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生
    Lock 在发生异常时,如果没有主动通过 unLock() 去释放锁,则很可能造成死锁现象,因此使用 Lock 时需要在 finally 块中释放锁
  • Lock 可以让等待锁的线程响应中断,而 synchronized 却不行,使用synchronized 时,等待的线程会一直等待下去,不能够响应中断
  • 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。
  • Lock 可以提高多个线程进行读操作的效率
    • 在性能上来说,如果竞争资源不激烈,两者的性能是差不多的
    • 而当竞争资源非常激烈时(即有大量线程同时竞争),此时 Lock 的性能要远远优于synchronized

三、线程间通信

  • 线程间通信的模型有两种:共享内存和消息传递,以下方式都是基本这两种模型来实现的
  • 有了进程间通信,我们的多线程编程步骤在操作方法上就增加了内容:
    • 第一步,创建资源类在资源类中创建属性和操作方法
    • 第二步,设定资源类中的操作方法
      • 判断 【是否要加锁】
      • 干活 【当前操作方法要完成的任务】
      • 通知 【唤醒其他等待的线程】
    • 第三步,创建多个线程调用资源类的操作方法
  • 案例要求:两个线程,一个线程对当前数值加 1,另一个线程对当前数值减 1,要求用线程间通信

Synchronized 实现

Java中,synchronized关键字可用于保证线程安全,实现了线程的互斥访问。在synchronized块中,线程可以等待某些条件的到来,或者通知其他线程已经满足了条件。wait()和notify() 就是用于实现线程之间相互通信的两个重要方法

wait()方法是Object类中的一种,它将当前线程置于睡眠状态,并且释放对象的锁。在等待期间,线程不会占用CPU

  • 当调用wait()方法时,线程会经历以下步骤:
    (1) 释放获取到的对象锁。
    (2) 线程等待通知,直到满足某个特定条件。
    (3) 线程在接到通知或者被interrupt()中断后,重新获取对象锁。
    (4) 线程继续执行wait()方法后的代码。

wait()方法有三种重载形式:

  • public final void wait() throws InterruptedException:
    • 让线程等待,直到其他线程调用该对象的notify()或notifyAll()方法。
  • public final void wait(long timeout) throws InterruptedException:
    • 让线程等待,最多等待timeout毫秒,直到其他线程调用该对象的notify()或notifyAll()方法
    • 如果在等待的过程中超时,则自动唤醒该线程。
  • public final void wait(long timeout, int nanos) throws InterruptedException:
    • 让线程等待,最多等待timeout毫秒和nanos纳秒,直到其他线程调用该对象的notify()或notifyAll()方法
    • 如果在等待的过程中超时,则自动唤醒该线程。

notify() 和 notifyAll() 被用于唤醒等待的线程

notify() 和 notifyAll()的主要区别:

  • notify()只随机唤醒一个等待线程,而notifyAll()唤醒所有等待线程
  • notify()只唤醒某个等待线程,但是无法保证唤醒的是哪个,所以通常在多个线程在等待时使用notifyAll()
  • notify()和notifyAll()都会使线程重新竞争锁,但notifyAll()比notify()更加安全,在通知之后,所有的线程都有机会参与竞争锁

1️⃣ 通过 Synchronized 实现

package com.atguigu.sync;

/**
 * @author Bonbons
 * @version 1.0
 * 进程间通信:
 *  两个线程,一个线程对当前数值加 1,另一个线程对当前数值减 1,要求用线程间通信
 *  使用Synchronized关键字实现
 */
public class TestVolatile {
    public static void main(String[] args) {
        Share share = new Share();
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    share.incr();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }, "AA").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    share.decr();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }, "BB").start();
    }
}

//资源类
class Share{
    //共享资源
    private int num = 0;
    //操作方法
    public synchronized void incr() throws InterruptedException {
        //判断
        if(num != 0){
            this.wait();
        }

        //干活
        num++;
        System.out.println(Thread.currentThread().getName() + " :: " + num);
        //通知
        this.notifyAll();
    }

    public synchronized void decr() throws InterruptedException {
        //判断
        if(num != 1){
            this.wait();
        }
        //干活
        num--;
        System.out.println(Thread.currentThread().getName() + " :: " + num);
        //通知
        this.notifyAll();
    }
}

【JUC高并发编程】—— 初见JUC_第8张图片

Lock 实现

ConditionJava 并发包中的一种线程通信机制,可以用于协调线程之间的唤醒和等待

  • Condition 接口提供了一个 newCondition() 方法,该方法返回一个与当前 Lock 绑定的 Condition 对象,可以用于唤醒等待在该 Condition 上的线程

  • 具体来说,newCondition() 方法会返回一个 Condition 对象,它与当前 Lock 相关联

  • 通过调用 condition.await() 方法,一个线程可以将自己挂起等待,并释放锁,直到其他线程通过调用 condition.signal()condition.signalAll() 方法来通知它继续执行

  • 当有一个或多个线程处于等待状态时,它们可以通过 condition.signal() 方法来唤醒其中一个线程,或通过 condition.signalAll() 方法来唤醒所有等待线程

使用 Condition 对象可以有效地避免线程竞争和死锁的问题,提高程序的运行效率和可靠性
它常常被用于多线程协作的场景中,例如生产者消费者模型或者读写锁的实现中

2️⃣ 通过 Lock 实现

package com.atguigu.lock;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @author Bonbons
 * @version 1.0
 * 进程间通信:
 *    两个线程,一个线程对当前数值加 1,另一个线程对当前数值减 1,要求用线程间通信
 *    使用Lock接口实现类,此处使用重入锁ReentrantLock实现
 */
public class LTestVolatile {
    public static void main(String[] args) {
        Share share = new Share();
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    share.incr();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }, "AA").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    share.decr();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }, "BB").start();
    }
}

class Share{
    private int num = 0;

    ReentrantLock lock = new ReentrantLock();
    Condition condition = lock.newCondition();
    public void incr() throws InterruptedException {
        lock.lock();
        try{
            if(num != 0){
                condition.await();
            }
            System.out.println(Thread.currentThread().getName() + " :: " + (++num));
            condition.signalAll();
        }finally {
            lock.unlock();
        }
    }

    public void decr() throws InterruptedException {
        lock.lock();
        try{
            if (num != 1){
                condition.await();
            }
            System.out.println(Thread.currentThread().getName() + " :: " + (--num));
        }finally {
            condition.signalAll();
            lock.unlock();
        }
    }
}

【JUC高并发编程】—— 初见JUC_第9张图片

上面的这两个案例其实设置了一个坑,存在虚假唤醒问题,当我们设置多个线程(超过两个)来完成这部分的功能,这个问题就会浮出水面

虚假唤醒问题

多线程通信是指在多个线程间共享数据,一个线程需要等待其它线程的操作结果才能继续执行的情况

而虚假唤醒(Spurious Wakeup)问题,是指一个线程在没有被通知或者没有被条件满足的情况下被唤醒,这种唤醒是不符合预期的,称为虚假唤醒

虚假唤醒问题在多线程编程中是比较常见的,它可能会导致程序出现逻辑错误、数据异常等问题

因此在多线程编程中,需要对虚假唤醒进行处理,以保证程序的正确性和可靠性。

通常解决虚假唤醒问题的方法,是在等待前判断条件是否满足,如果不满足则等待,这样可以避免虚假唤醒引起的问题

在Java中,可以使用while循环来进行条件判断和等待,例如:

synchronized (lock) {
    while (!condition) {
        lock.wait();
    }
    // 执行需要等待的操作
}

上面的代码在等待之前使用while循环判断条件是否满足,如果条件不满足则一直等待,直到条件满足才会执行需要等待的操作。这样可以有效地避免虚假唤醒引起的问题。

另外,还可以使用Java提供的Condition对象来实现精准的线程等待和唤醒,Condition对象可以理解为是一个等待队列,它能够使得指定的线程等待并放弃锁,同时通知其它线程进行通知(signal)

在使用Condition对象时,需要先通过Lock对象创建一个Condition实例,然后在等待前使用await()方法进行等待,等待时会释放Lock对象的锁,直到其它线程通过signal()方法进行通知,Condition对象再次获取Lock对象的锁,继续执行下面的代码。例如:

Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();

lock.lock();
try {
    while (!conditionSatisfied) {
        condition.await();
    }
    // 执行需要等待的操作
} catch (InterruptedException e) {
    // 处理中断异常
} finally {
    lock.unlock();
}

上面的代码使用Lock对象创建了一个Condition实例,然后在等待前使用await()方法进行等待,等待时会释放Lock对象的锁,直到其它线程通过signal()方法进行通知,Condition对象再次获取Lock对象的锁,继续执行下面的代码。

总之,对于多线程通信时的虚假唤醒问题,我们需要在进行等待操作前先进行条件判断,确保只有在条件满足时才会进行等待。此外,使用Condition对象能够更加精确地进行线程等待和唤醒,从而避免虚假唤醒引起的问题。


四、线程定制化通信

要实现线程定制化通信,可以使用以下方式:

  • 使用共享内存
    • 线程可以访问共享内存,从而实现线程间通信
    • 但是,使用共享内存需要进行同步和互斥操作,以避免数据竞争问题
  • 使用消息队列
    • 线程可以通过向消息队列发送消息来通信
    • 消息队列的实现可以是从共享内存中读取和写入数据的线程安全实现。
  • 使用管道
    • 类似于消息队列的概念,但是使用一个进程间的缓冲区,可以在两个线程之间传递数据。
  • 使用信号量
    • 信号量是一种同步机制,在线程之间通信时可以使用
    • 当某个线程需要访问共享资源时,它会发出一个请求信号,然后在资源可用时被唤醒。
  • 使用条件变量
    • 线程可以使用条件变量使自己进入休眠状态,直到满足某个条件时被唤醒
  • 使用套接字
    • 套接字既可以用于本地通信,也可以用于网络通信
    • 线程可以通过套接字进行通信

在选择线程间通信的方式时,应该根据特定的应用程序需求和环境条件来选择最优的实现方式

案例分析:A 线程打印 5 次 A,B 线程打印 10 次 B,C 线程打印 15 次 C,按照此顺序循环 10 轮

package com.atguigu.lock;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @author Bonbons
 * @version 1.0
 * 使用ReentrantLock定制化多线程通信
 * A 线程打印 5 次 A,B 线程打印 10 次 B,C 线程打印 15 次 C,按照此顺序循环 10 轮
 */
public class ThreadCommunication {
    public static void main(String[] args) {
        ThreadDemo threadDemo = new ThreadDemo();
        new Thread(() -> {
            try {
                threadDemo.print5();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }, "c1").start();

        new Thread(() -> {
            try {
                threadDemo.print10();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }, "c2").start();

        new Thread(() -> {
            try {
                threadDemo.print15();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }, "c3").start();
    }
}

class ThreadDemo{
    private int num = 0;
    private ReentrantLock lock = new ReentrantLock();
    //三个通信钥匙
    Condition c1 = lock.newCondition();
    Condition c2 = lock.newCondition();
    Condition c3 = lock.newCondition();
    //三个方法
    public void print5() throws InterruptedException {
        lock.lock();
        try{
            if(num != 0){
                c1.await();
            }
            for (int i = 0; i < 5; i++) {
                System.out.println(Thread.currentThread().getName() + " :: " + num);
            }
            num = 1;
            c1.signalAll();
        }finally {

            lock.unlock();
        }
    }
    public void print10() throws InterruptedException {
        lock.lock();
        try{
            if(num != 1){
                c1.await();
            }
            for (int i = 0; i < 10; i++) {
                System.out.println(Thread.currentThread().getName() + " :: " + num);
            }
            num = 2;
            c1.signalAll();
        }finally {
            lock.unlock();
        }
    }
    public void print15() throws InterruptedException {
        lock.lock();
        try{
            if(num != 2){
                c1.await();
            }
            for (int i = 0; i < 15; i++) {
                System.out.println(Thread.currentThread().getName() + " :: " + num);
            }
            num = 0;
            c1.signalAll();
        }finally {
            lock.unlock();
        }
    }
}

【JUC高并发编程】—— 初见JUC_第10张图片
synchronized实现同步的基础:Java中的每一个对象都可以作为锁。具体表现为以下3种形式。对于普通同步方法,锁是当前实例对象。对于静态同步方法,锁是当前类的class.对象。对于同步方法块,锁是Synchonized括号里配置的对象

你可能感兴趣的:(Java并发编程,java,jvm,面试,juc,多线程)