【Java 】JUC并发编程

Java 并发

引入

1、程序、进程、线程的概念

程序

​ 通常为了完成特定的任务、用某种语言编写的一组指令的结合。即一段静态的代码

进程

​ 程序的一次执行,或者是正在运行的一段程序,是一个动态过程,有自身的生命周期

  • 如:运行中的QQ,运行中的MP3播放器
  • 程序是静态的,进程是动态的
  • 进程作为资源分配的单位,系统在运行时会为每个进程分配不同的内存区域

线程

​ 进程可进一步细化为线程,是一个程序内部的一条执行路径

  • 线程作为调度和执行的单位,每个线程拥有独立的运行栈和程序计数器(pc)
  • 一个进程中的多个线程共享相同的内存单元/内存地址空间它们从同一堆中分配对象,可以 访问相同的变量和对象。这就使得线程间通信更简便、高效。但多个线程操作共享的系统资 源可能就会带来安全的隐患。

2、并行与并发

​ **并行:**指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的。

【Java 】JUC并发编程_第1张图片

​ **并发:**指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。

【Java 】JUC并发编程_第2张图片

3、多线程程序的优点

  1. 提高应用程序的响应。对图形化界面更有意义,可增强用户体验。
  2. 提高计算机系统CPU的利用率
  3. 改善程序结构。将既长又复杂的进程分为多个线程,独立运行,利于理解和 修改

一、使用线程

有三种使用线程的方法:

  • 实现 Runnable 接口;
  • 实现 Callable 接口;
  • 继承 Thread 类。

实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以理解为任务是通过线程驱动从而执行的

继承 Thread 类

同样也是需要实现 run() 方法,因为 Thread 类也实现了 Runable 接口

- Thread():创建新的Thread对象 

- Thread(String threadname):创建线程并指定线程实例名 

- Thread(Runnable target):指定创建线程的目标对象,它实现了Runnable接 口中的run方法 

- Thread(Runnable target, String name):创建新的Thread对象

在这里插入图片描述

当调用 start() 方法启动一个线程时,虚拟机会将该线程放入就绪队列中等待被调度,当一个线程被调度时会执行该线程的 run() 方法。

public class MyThread extends Thread {
    public void run() {
        // ...
    }
}
public static void main(String[] args) {
    MyThread mt = new MyThread();
    mt.start();
}

实现 Runnable 接口

需要实现接口中的 run() 方法。

    public static class myRunable implements Runnable{
        private int ticket=10;
        String threadName;
        myRunable(String name){
            super();
            this.threadName=name;
        }
        @Override
        public void run() {
            for(int i=0;i<10;++i){
                if(ticket<0){
                    break;
                }
                System.out.println(threadName + "线程售出票据 :"+ticket);
                --ticket;
            }
        }
    }

使用 Runnable 实例再创建一个 Thread 实例,然后调用 Thread 实例的 start() 方法来启动线程。

    public static void main(String[] args) throws IOException {
        myRunable myRunable1 = new myRunable("1号");
        for(int i=0;i<2;++i){
            Thread thread = new Thread(myRunable1);
            thread.start();
        }
    }

【Java 】JUC并发编程_第3张图片

  • 10号出现两次的原因是因为临界区未上锁,但至少实现了线程之间资源的共享

实现接口 VS 继承 Thread

实现接口会更好一些,因为:

  • Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口,避免了单继承的局限性
  • 对于继承Thread,一个类的多个Thread线程共享的数据必须设置为static,非static属性无法在多个线程中共享而实现Runable接口共享的数据不需要特点指明为static
  • 线程池只能放入实现Runable或callable类线程,不能直接放入继承Thread的类
  • main函数,实例化线程对象也有所不同,
    extends Thread :t.start();
    implements Runnable : new Thread(t).start();

实现 Callable 接口

与 Runnable 相比,Callable 可以有返回值,返回值通过 FutureTask 进行封装,同时实现Callable接口能够抛出异常和支持泛型。

为什么Callable同样能够通过Thread运行呢?因为需要一个中间件FutureTask进行封装,FutureTask继承于RunableFutureRunnableFuture同时继承于RunableFuture

  • Future接口
    • 可以对具体Runnable、Callable任务的执行结果进行取消、查询是否完成、获取结果等。
    • FutrueTask是Futrue接口的唯一的实现类
    • FutureTask 同时实现了Runnable,Future接口。它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值
public class MyCallable implements Callable<Integer> {
    public Integer call() {
        return 123;
    }
} 
public static void main(String[] args) throws ExecutionException, InterruptedException {
    MyCallable mc = new MyCallable();
    FutureTask<Integer> ft = new FutureTask<>(mc);
    Thread thread = new Thread(ft);
    thread.start();
    System.out.println(ft.get());
}

需要注意的几点:

  • 在调用FutureTask的get方法时该线程会被阻塞,只有等到线程中的call方法执行完毕返回结果时阻塞才会结束,通常将get方法最后使用。
  • 同一个Callable任务多次执行只会执行一次,这是因为相同任务返回的结果被缓存。

二、基础线程机制

Executor(线程池)

因为频繁创建线程和销毁线程需要时间,如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率。Executor 将为你管理核心线程,方便线程复用,同时可以控制最大并发数量。

Executor在客户端和任务执行之间提供了一个间接层。

线程池的创建及重要参数

 public ThreadPoolExecutor(int corePoolSize,
                           int maximumPoolSize,
                           long keepAliveTime,
                           TimeUnit unit,
                           BlockingQueue<Runnable> workQueue,
                           ThreadFactory threadFactory,
                           RejectedExecutionHandler handler) {……}

ThreadPoolExecutor中重要的七大参数详解

  • corePoolSize(必填):核心线程数,也是线程池中常驻的线程数,线程池初始化时默认是没有线程的,当任务来临时才开始创建线程去执行任务
  • maximumPoolSize(必填):最大线程数,在核心线程数的基础上可能会额外增加一些非核心线程,需要注意的是只有当workQueue队列填满时才会创建多于corePoolSize的线程(线程池总线程数不超过maxPoolSize)
  • keepAliveTime(必填):非核心线程的空闲时间超过keepAliveTime就会被自动终止回收掉,注意当corePoolSize=maxPoolSize时,keepAliveTime参数也就不起作用了(因为不存在非核心线程);
  • unit(必填):keepAliveTime的时间单位。常用的有:TimeUnit.MILLISECONDS(毫秒)、TimeUnit.SECONDS(秒)、TimeUnit.MINUTES(分)
  • workQueue(必填):用于保存任务的队列,可以为无界、有界、同步移交三种队列类型之一,当池子里的工作线程数大于corePoolSize时,这时新进来的任务会被放到队列中
  • threadFactory(选填):创建线程的工厂类,默认使用Executors.defaultThreadFactory(),也可以使用guava库的ThreadFactoryBuilder来创建
  • handler(选填):线程池无法继续接收任务(队列已满且线程数达到maximunPoolSize)时的饱和策略,取值有AbortPolicy、CallerRunsPolicy、DiscardOldestPolicy、Discard Policy

线程池中的线程创建流程图:

【Java 】JUC并发编程_第4张图片

举个栗子:现有一个线程池,corePoolSize=10,maxPoolSize=20,队列长度为100,那么当任务过来会先创建10个核心线程数,接下来进来的任务会进入到队列中直到队列满了,会创建额外的线程来执行任务(最多20个线程),这个时候如果再来任务就会执行拒绝策略。

workQueue队列

  • SynchronousQueue(同步移交队列):队列不作为任务的缓冲方式,可以简单理解为workQueue长度为零
  • LinkedBlockingQueue(无界队列):队列长度不受限制,当请求越来越多时(任务处理速度跟不上任务提交速度造成请求堆积)可能导致内存占用过多或OOM,也可以通过构造函数添加长度限制。
  • ArrayBlockintQueue(有界队列):队列长度受限,当队列满了就需要创建多余的线程来执行任务

handler拒绝策略

  • AbortPolicy(默认):中断抛出异常
  • DiscardPolicy:默默丢弃任务,不进行任何通知
  • DiscardOldestPolicy:丢弃掉在队列中存在时间最久的任务
  • CallerRunsPolicy:让提交任务的线程去执行任务(对比前三种比较友好一丢丢)

常见的线程池种类

主要有四种 Executor:

  • newCachedThreadPool 创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。核心线程数为0,最大线程数为最大值,每次来请求直接创建新线程来处理任务,也不使用队列缓冲,会自动回收多余线程,由于将maxPoolSize设置成Integer.MAX_VALUE,当请求很多时就可能创建过多的线程,导致资源耗尽OOM
  • newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。任务可以无限放入,当请求过多时(任务处理速度跟不上任务提交速度造成请求堆积)可能导致占用过多内存或直接导致OOM异常
  • newScheduledThreadPool :使用的构造方式为new ThreadPoolExecutor(var1, 2147483647, 0L, TimeUnit.NANOSECONDS, new ScheduledThreadPoolExecutor.DelayedWorkQueue()),支持定时周期性执行,注意一下使用的是延迟队列,弊端同newCachedThreadPool一致
  • newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行,每个任务都会在下一个任务开始之前运行结束.
// LinkedBlockingQueue  等待队列无限长
// 核心线程、最大线程数均给定
public static ExecutorService newFixedThreadPool(int var0) {
    return new ThreadPoolExecutor(var0, var0, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue());
}

// LinkedBlockingQueue  等待队列无限长
// 核心线程、最大线程数均是1
public static ExecutorService newSingleThreadExecutor() {
    return new Executors.FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue()));
}
// 无限创建工作线程
public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, 2147483647, 60L, TimeUnit.SECONDS, new SynchronousQueue());
}
// 无限创建线程
public static ScheduledExecutorService newScheduledThreadPool(int var0) {
    return new ScheduledThreadPoolExecutor(var0);
}

在实际项目开发中也是推荐使用手动创建线程池的方式,而不用默认方式,关于这点在《阿里巴巴开发规范》中是这样描述的:

img

线程相关的API

  • JDK5.0起提供了线程池相关API:ExecutorServiceExecutors

  • ExecutorService:真正的线程池接口。常见子类ThreadPoolExecutor

    • void execute(Runnable command):执行任务/命令,没有返回值,一般用来执行Runnable和Thread
    • Future submit(Callabletask):执行任务,有返回值,一般又来执行Callable
    • void shutdown():关闭连接池,不允许新的线程进入线程池,等待已进入线程执行完毕
    • void shutdownNow(): 关闭线程池,同时向该线程池中的所有任务发送interrupt()指令,所有线程状态被设置为STOP
  • Executors:工具类、线程池的工厂类,用于创建并返回不同类型的线程池

    • Executors.newCachedThreadPool():创建一个可根据需要创建新线程的线程池
    • Executors.newFixedThreadPool(n);创建一个可重用固定线程数的线程池
    • Executors.newSingleThreadExecutor():创建一个只有一个线程的线程池
    • Executors.newScheduledThreadPool(n):创建一个线程池,它可安排在给定延迟后运行命令或者定期地执行。
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newCachedThreadPool();
        for (int i = 0; i < 5; i++) {
            executorService.execute(new MyRunnable());
        }
        executorService.shutdown();
    }
    

线程池与Callable

  • 线程池执行Callable通常有两种方法:先创建FutureTask执行、直接创建Callable执行返回FutureTask

自定义实现Callable接口线程类

        class MyCallable implements Callable<Integer>{
            @Override
            public Integer call() throws Exception {
                System.out.println(Thread.currentThread().getName()+"被执行");
                return 1;
            }
        }

方法一:返回FutureTask

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService executorService=Executors.newCachedThreadPool();
        for(int i=0;i<10;++i){
            Future<Integer> submit = executorService.submit(new MyCallable());
            System.out.println("返回的结果值:"+submit.get());
        }
    }

方法二:创建FutureTask执行

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService executorService=Executors.newCachedThreadPool();
        for(int i=0;i<10;++i){
            FutureTask<Integer> futureTask = new FutureTask<>(new MyCallable());
            executorService.submit(futureTask);
            System.out.println("返回的结果值:"+futureTask.get());
        }
    }

​ ⭐️ 这里值得注意的是:如果创建了多个MyCallable对象,这些进程不会共享MyCallable中的数据,类似Runnable被Thread的执行过程,如果需要多个Callable共享数据,创建唯一的Callable(当做Thread),创建多个FutureTask(当做Runnable),再将FutureTask调用submit在线程池中执行

线程池的使用实例

import java.util.concurrent.*;

public class Test1 {

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        Ticket tickets = new Ticket(10);
        // 创建唯一的Callable实例用于共享数据
        TicketWindow tw = new TicketWindow(tickets);
        //创建线程池
        ExecutorService service = Executors.newCachedThreadPool();
        for(int i=1; i<4; i++){
            //创建线程 
            //注意,每个线程都需要new一个新的FutureTask,否则会出现只有一个窗口卖票
            FutureTask<Void> ft = new FutureTask<Void>(tw);
            service.submit(ft);
        }                                                                                               
        service.shutdown();
    }
}

class TicketWindow implements Callable<Void>{
    //private int tickets = 10;//车票总量

    private Ticket tickets;

    public TicketWindow(Ticket tickets){
        this.tickets = tickets;
    }

    @Override
    public Void call(){
        while(true){
            //设置同步代码块
            synchronized (tickets) {
                int currentNo = tickets.getNum();
                if(currentNo>0){
                    System.out.println(Thread.currentThread().getName() + "准备出票,剩余票数:" + currentNo + "张");
                    --currentNo;
                    tickets.setNum(currentNo);
                    System.out.println(Thread.currentThread().getName() + "卖出一张,剩余票数:" + currentNo + "张");
                    try {
                        Thread.sleep(500);//出票成功后让当前售票窗口睡眠,以便让其他售票窗口卖票

                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    //Thread.yield();
                }
                else{
                    System.out.println(Thread.currentThread().getName() + "余票不足,停止售票!");
                    break;
                }
            }
        }
        return null;
    }
}
// 票对象
class Ticket{
    private int num;
    public Ticket(int n){
        this.num = n;
    }
    public void setNum(int n){
        this.num = n;
    }
    public int getNum(){
        return num;
    }
}

Daemon 设置守护线程

Java中的线程分为两类:一种是守护线程,一种是用户线程

 它们在几乎每个方面都是相同的,唯一的区别是判断JVM何时离开。

 守护线程是用来服务用户线程的,通过在start()方法前调用 thread.setDaemon(true)可以把一个用户线程变成一个守护线程。

 Java垃圾回收就是一个典型的守护线程。

 若JVM中都是守护线程,当前JVM将退出。

 形象理解:兔死狗烹,鸟尽弓藏

setDaemon() 方法可以将一个线程设置为守护线程。

public static void main(String[] args) {
    Thread thread = new Thread(new MyRunnable());
    thread.setDaemon(true);
}

sleep()

Thread.sleep(millisec) 方法会休眠当前正在执行的线程,millisec 单位为毫秒。

sleep() 可能会抛出 InterruptedException,因为异常不能跨线程传播回 main() 中,因此必须在本地进行处理。线程中抛出的其它异常也同样需要在本地进行处理。

public void run() {
    try {
        Thread.sleep(3000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

yield()

对静态方法 Thread.yield() 的调用声明了当前线程已经完成了生命周期中最重要的部分,可以切换给其它线程来执行。该方法只是对线程调度器的一个建议,而且也只是建议具有相同优先级的其它线程可以运行

public void run() {
    Thread.yield();
}

priority优先级

CPU总是倾向于让高优先级的线程先执行,低优先级并不是不执行,而是执行的频率较低

Java中允许用户自定义优先级,有以下几个方法

  • setPriority() :设置一个线程的优先级
  • getPriority():读取当前线程的优先级
Thread.currentThread.setPriority()
Thread.currentThread.getPriority()

三、中断

一个线程执行完毕之后会自动结束,如果在运行过程中发生异常也会提前结束。

InterruptedException

通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞。

对于以下代码,在 main() 中启动一个线程之后再中断它,由于线程中调用了 Thread.sleep() 方法,因此会抛出一个 InterruptedException,从而提前结束线程,不执行之后的语句。

public class InterruptExample {

    private static class MyThread1 extends Thread {
        @Override
        public void run() {
            try {
                Thread.sleep(2000);
                System.out.println("Thread run");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
public static void main(String[] args) throws InterruptedException {
    Thread thread1 = new MyThread1();
    thread1.start();
    thread1.interrupt();
    System.out.println("Main run");
}
Main run
java.lang.InterruptedException: sleep interrupted
    at java.lang.Thread.sleep(Native Method)
    at InterruptExample.lambda$main$0(InterruptExample.java:5)
    at InterruptExample$$Lambda$1/713338599.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:745)

interrupted()

如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等会抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束。

但是调用 interrupt() 方法会设置线程的中断标记,此时调用 interrupted() 方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程。

public class InterruptExample {

    private static class MyThread2 extends Thread {
        @Override
        public void run() {
            while (!interrupted()) {
                // ..
            }
            System.out.println("Thread end");
        }
    }
}
public static void main(String[] args) throws InterruptedException {
    Thread thread2 = new MyThread2();
    thread2.start();
    thread2.interrupt();
}
Thread end

Executor 的中断操作

调用 Executor 的 shutdown() 方法会等待线程都执行完毕之后再关闭,但是如果调用的是 shutdownNow() 方法,则相当于调用每个线程的 interrupt() 方法。

以下使用 Lambda 创建线程,相当于创建了一个匿名内部线程。

public static void main(String[] args) {
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> {
        try {
            Thread.sleep(2000);
            System.out.println("Thread run");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });
    executorService.shutdownNow();
    System.out.println("Main run");
}
Main run
java.lang.InterruptedException: sleep interrupted
    at java.lang.Thread.sleep(Native Method)
    at ExecutorInterruptExample.lambda$main$0(ExecutorInterruptExample.java:9)
    at ExecutorInterruptExample$$Lambda$1/1160460865.run(Unknown Source)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
    at java.lang.Thread.run(Thread.java:745)

如果只想中断 Executor 中的一个线程,可以通过使用 submit() 方法来提交一个线程,它会返回一个 Future 对象,通过调用该对象的 cancel(true) 方法就可以中断线程。

Future<?> future = executorService.submit(() -> {
    // ..
});
future.cancel(true);

四、互斥同步

为了解决资源共享的竞争问题,Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问,第一个是 JVM 实现的 synchronized,而另一个是 JDK 实现的 ReentrantLock。

synchronized

当任务执行到synchronize关键字保护的代码段的时候,会检查锁是否可用,然后获取锁、执行代码、释放锁,对于需要同步的数据成员将其声明为private是很重要的,以防其他任务直接访问域,这样会造成资源冲突.每一个访问临界资源的方法都需要加锁。

Java中的锁更像是操作系统中的整型信号量,如果该信号量为0说明该锁已经被释放,每一次加锁都会使其+1,释放锁会使其-1.

特别提示:锁住的不是方法,也不是代码块,而是对象,并且是同一个对象。也就是说一个线程正在调用该对象的同步方法,如果还没有执行完成,那么另一个线程就无法调用该对象的所有同步方法或代码块(会阻塞)。同样的,如果一个线程拿到了锁,那么他可以调用该类的所有同步方法

1. 同步一个代码块

public void func() {
    synchronized (this) {
        // ...
    }
}

它只作用于同一个对象,如果调用两个对象上的同步代码块,就不会进行同步。

对于以下代码,使用 ExecutorService 执行了两个线程,由于调用的是同一个对象的同步代码块,因此这两个线程会进行同步,当一个线程进入同步语句块时,另一个线程就必须等待。

public class SynchronizedExample {

    public void func1() {
        synchronized (this) {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        }
    }
}
public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func1());
    executorService.execute(() -> e1.func1());
}

​ 对于以下代码,两个线程调用了不同对象的同步代码块,因此这两个线程就不需要同步。从输出结果可以看出,两个线程交叉执行。

public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    SynchronizedExample e2 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func1());
    executorService.execute(() -> e2.func1());
}

​ 对于synchronized()中的参数是该同步代码块的锁,锁需要是对象下唯一的属性,例如:this,但是如果是多个对象,其this分别不同,就无法起到同步的效果,如果想要达到多个对象也能实现同步,这时需要给同步代码块一把当前作用域下唯一的锁

class MyThread implements Runnable{
    Object lock=new Object();
    MyThread(Object lock){
        this.lock=lock;
    }
    @Override
    public void run(){
        //synchronized (lock) 可实现多对象的互斥
        synchronized (this){
            for(int i=0;i<5;++i){
                System.out.println(Thread.currentThread().getName() + "    ++i");
            }
        }
    }
}
public class TestLock {
    public static void main(String[] args) {
        Object lock = new Object();
        MyThread thread=new MyThread(lock);
        MyThread thread2=new MyThread(lock);
        Thread thread1 = new Thread(thread,"线程1");
        Thread thread3=new Thread(thread2,"线程2");
        thread1.start();
        thread3.start();
    }
}
  • 这里创建了多个对象,锁依然是this,非唯一的锁所以不同对象访问无法实现互斥

【Java 】JUC并发编程_第5张图片

  • 如果给一把全局唯一的锁,例如main函数中的lock,将lock替代this那么就能够实现多对象同步互斥,通常这把全局唯一的锁使用this.getClass(),其在this的运行时类在所有对象中唯一的

【Java 】JUC并发编程_第6张图片

2. 同步一个类

public void func() {
    // MyRunnable.class 同样可以
    synchronized (this.getClass()) {
        // ...
    }
}

作用于整个类,也就是说两个线程调用同一个类的不同对象上的这种同步语句,也会进行同步。

3. 同步一个方法

public synchronized void func () {
    // ...
}

它和同步代码块一样,作用于同一个对象。

4. 同步一个静态方法

public synchronized static void fun() {
    // ...
}

作用于整个类。

对于同步方法的补充

  • 同步方法的锁为this,同步静态方法的锁为this.class
  • 通过继承Thread 实现线程中多个Thread对象中 this 并不唯一,所以其同步方法必须是同步静态方法才能实现资源的互斥

Lock

Lock是一个接口类,其实现类有:

【Java 】JUC并发编程_第7张图片

Java中已经有了synchronized为什么还需要引入Lock呢?

​ 答:lock相较于synchronize更加灵活,提供了比synchronized更多的功能

public interface Lock {
  void lock();
  void lockInterruptibly() throws InterruptedException;
  boolean tryLock();
  boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
  void unlock();
  Condition newCondition();
}

Lock是一个接口,ReentrantLock 是Lock的实现类,常用的方法如下

  • lock() 用来获取锁。如果锁已被其他线程获取,则进行等待
  • unlock() 释放该锁供其他线程获取
  • tryLock() 尝试去获得锁,如果获得了锁返回true,否则返回false,该方法会立刻返回,不会进行等待
  • tryLock(long time, TimeUnit unit) 与trylock类似,不过会等待一段时间

一般拿取锁的步骤,

Lock lock = ...;
if(lock.tryLock()) {
   try{
     //处理任务
  }catch(Exception ex){
    
   }finally{
     lock.unlock();//释放锁
   } 
}else{
  //如果不能获取锁,则直接做其他事情
}
public class LockExample {
    private Lock lock = new ReentrantLock();

    public void func() {
        lock.lock();
        try {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        } finally {
            lock.unlock(); // 确保释放锁,从而避免发生死锁。
        }
    }
}
public static void main(String[] args) {
    LockExample lockExample = new LockExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> lockExample.func());
    executorService.execute(() -> lockExample.func());
}

​ 与同步代码块中的锁相同,锁的作用域决定了同步范围

Synchronize与lock的比较

1、原始构成
synchronized是java关键字,属于JVM层面;
lock是具体类,java.util.concurrent.locks.lock,是API层面

2、使用方法
synchronized不需要用户手动释放锁,执行完成,系统自动让线程释放锁;
reentrantlock需要用户手动释放锁,可能导致出现死锁现象,需要lock、unlock方法配合try\catch语句块来完成

3、等待是否可中断
synchronized不可中断,除非正常完成或者抛出异常;
renntrantlock可中断:
(1)设置超时方法trylock(long timeout,TimeUnit unit)
(2)lockInterruptibly()放代码块中,调用interrupt()方法中断

4、加锁是否公平
synchronized非公平锁;
reentrantlock可公平可不公平,默认是非公平锁,构造方法传入true则为公平锁,传入false则为非公平锁

5、锁绑定多个条件Condition
synchronized没有,要么随机唤醒一个线程,要么唤醒全部线程;
reentrantlock用来实现分组唤醒或精确唤醒


readWriteLock

顾名思义这种锁成为**读写锁**,在需要对数据进行读、写操作时需要加锁。

那么问题来了,已经有了ReetrantLock与synchronize之后,为什么还需要读写锁呢?

那是因为ReetrantLock是独占式(排他) 锁,即当线程1获取到资源的时候,其他线程不能再来操作共享资源了。就算是ReetrantLock的操作是读取的时候,其他线程也不能读取共享资源的操作。这在现实生活中是不符合逻辑的,而且性能也比较慢。所以就有了读写锁的出现。

  • 读取数据时,通常希望其他线程可以一同读取数据但是不能修改数据,读锁的目的就是防止其他线程在该线程读取时进行修改。

  • 修改数据时,希望其他线程无法读取并且无法修改数据,确保当前修改不会造成数据覆盖与幻读,写锁的目的是该线程写入时防止其他线程读取与修改数据。

Java中读写锁的通用接口是readWriteLock,其唯一的实现类是ReetrantReadWriteLock,该实现类维护一对关联的锁分别是读锁ReadLock与写锁WriteLock,确保了一次只有一个线程( 写入线程)可以修改共享数据,在许多情况下,任何数量的线程都可以同时读取数据。

ReentrantReadWriteLock默认是非公平锁,同样能够使用构造器传入boolean值修改为公平锁。

【Java 】JUC并发编程_第8张图片

只有两个常用的方法:

  • WriteLock() 返回一个写锁的对象
  • ReadLock() 返回一个读锁的对象

读锁、写锁对象均是ReetrantReadWriteLock中的内部类,通过lockunlock进行锁的操作

五、线程之间的协作

当多个线程可以一起工作去解决某个问题时,如果某些部分必须在其它部分之前完成,那么就需要对线程进行协调。

join()

在线程中调用另一个线程的 join() 方法,会将当前线程挂起,而不是忙等待,直到目标线程结束

对于以下代码,虽然 b 线程先启动,但是因为在 b 线程中调用了 a 线程的 join() 方法,b 线程会等待 a 线程结束才继续执行,因此最后能够保证 a 线程的输出先于 b 线程的输出。

public class JoinExample {

    private class A extends Thread {
        @Override
        public void run() {
            System.out.println("A");
        }
    }

    private class B extends Thread {

        private A a;

        B(A a) {
            this.a = a;
        }

        @Override
        public void run() {
            try {
                a.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("B");
        }
    }

    public void test() {
        A a = new A();
        B b = new B(a);
        b.start();
        a.start();
    }
}
public static void main(String[] args) {
    JoinExample example = new JoinExample();
    example.test();
}
A
B

wait() notify() notifyAll()

调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。

通常notify()用来代替notifyAll()会更加安全,因为notify()只会在众多等待同一个锁任务的线程唤醒一个,而notifyAll能唤醒所有所有等待该锁的任务。

notifyAll()会唤醒“所有正在等待的任务”这样的理解是错误的,当notifyAll()因某个特定的锁被调用是,只有等待这个锁的所有任务会被唤醒

它们都属于 Object 的一部分,而不属于 Thread。

只能用在同步方法或者同步控制块中使用,否则会在运行时抛出 IllegalMonitorStateException。

使用 wait() 挂起期间,线程会释放锁。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。

public class WaitNotifyExample {

    public synchronized void before() {
        System.out.println("before");
        notifyAll();
    }

    public synchronized void after() {
        try {
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("after");
    }
}
public static void main(String[] args) {
    ExecutorService executorService = Executors.newCachedThreadPool();
    WaitNotifyExample example = new WaitNotifyExample();
    executorService.execute(() -> example.after());
    executorService.execute(() -> example.before());
}
before
after

Condition

在JDK5.0以后,JAVA提供了新的更加健壮的线程处理机制,包括了同步、锁定、线程池等等,可以实现更小粒度上的控制。await()和signal()就是其中用来同步的两种方法,功能基本上和wait()/notify()相同,完全可以取代它们,但是它们和新引入的锁定机制Lock直接挂钩,具有更大的灵活性,能够实现精确唤醒

java.util.concurrent 类库中提供了 Condition 类来实现线程之间的协调,可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal()signalAll() 方法唤醒等待的线程。

相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活

使用 Lock调用lock.newCondition() 来获取一个 Condition 对象。

public class AwaitSignalExample {

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

    public void before() {
        lock.lock();
        try {
            System.out.println("before");
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public void after() {
        lock.lock();
        try {
            condition.await();
            System.out.println("after");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}
public static void main(String[] args) {
    ExecutorService executorService = Executors.newCachedThreadPool();
    AwaitSignalExample example = new AwaitSignalExample();
    executorService.execute(() -> example.after());
    executorService.execute(() -> example.before());
}
before
after

Condition实现精确唤醒

前面说到Condition能够精确唤醒某个线程,而不是像waitsignify搭配使用时只能够唤醒随机的一个线程,如何实现呢?

让每一个线程拥有一个Condition例如A线程拥有Condition_A,B线程拥有Condition_B,C线程拥有Condition_C,三个线程均是await()后的状态,如果需要唤醒任意一个线程,只需要调用其Condition的signal()即可。

例如需要唤醒A线程,调用Condition_A.signal(),这样就能实现精确唤醒,保证业务精确执行。

wait()与sleep()的区别

  • 线程调用sleep()不会将锁释放,调用wait()线程会释放锁
  • wait()有两种版本,一种是接受毫秒数作为参数,这一版本与Sleep()类似,另一种版本是不接受参数,wait()会无限等待下去。不同的是,wait()能够被notify()、norifyAll()唤醒而sleep()不行。
  • wait(),notify(),notifyAll()均是基类Object的一部分,而sleep是Thread类的静态方法
  • wait()方法必须在同步代码块或者同步方法中使用,sleep()可以在任意位置使用。同时sleep()需要捕获异常而wait()、notify()、notifyAll()不需要捕获。

有关线程合作的问题

线程的虚假唤醒

概念

代码中出现if判断与notifyAll语句希望配合使用实现线程合作时,因为条件的判断使用的是if,线程被notifyAll唤醒后并不会再进行判断导致所有被唤醒的进程都能够执行希望同步的代码,从而出现线程的虚假唤醒。

例如在消费者生产者问题中,线程A、B用于生产,线程C、D用于消费,如果A、B线程任意一个调用了notifyAll那么会导致C、D同时被唤醒从而进行一次消费,导致数据错误。

package com.sjmp.demo02PC;

/**
 * @author: sjmp1573
 * @date: 2020/11/24 18:54
 * @description:
 */

public class ConsumeAndProduct {
    public static void main(String[] args) {

        Data data = new Data();

//        创建一个生产者
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"A").start();
//         创建一个消费者
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"B").start();


        //        创建一个生产者
            new Thread(()->{
            for (int i = 0; i < 10; i++) {
                try {
                    data.decrement();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"C").start();
    //         创建一个消费者
            new Thread(()->{
            for (int i = 0; i < 10; i++) {
                try {
                    data.decrement();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"D").start();
    }
}
//这是一个缓冲类,生产和消费之间的仓库
class Data{
    //    这是仓库的资源,生产者生产资源,消费者消费资源
    private int num = 0;

    //    +1,利用关键字加锁
    public synchronized void increment() throws InterruptedException {
//        首先查看仓库中的资源(num),如果资源不为0,就利用 wait 方法等待消费,释放锁
//        使用 if 存在虚假唤醒
        while (num!=0){
            this.wait();
        }
        num++;
        System.out.println(Thread.currentThread().getName()+"=>"+num);
//      通知其他线程 +1 执行完毕
        this.notifyAll();
    }
    //    -1
    public synchronized void decrement() throws InterruptedException {
        //        首先查看仓库中的资源(num),如果资源为0,就利用 wait 方法等待生产,释放锁
        while(num==0){
            this.wait();
        }
        num--;
        System.out.println(Thread.currentThread().getName()+"=>"+num);
//        通知其他线程 -1 执行完毕
        this.notifyAll();
    }
}

解决措施

使用while替换if进行条件判断,这样做即便是线程被唤醒,在进入希望同步的代码块时至少会进行一次条件判断,这样就解决了线程的虚假唤醒问题。官方文档如下:

【Java 】JUC并发编程_第9张图片

六、安全集合类(待补充)

为什么需要使用线程安全的集合类呢?

在Java中,任何线程不安全的集合使用多线程操作时,可能会因为多个线程操作同一个集合对象而抛出java.util.ConcurrentModificationException异常j。

public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    for (int i = 0; i < 100; ++i) {
        new Thread(() -> {
            String str = UUID.randomUUID().toString().substring(0, 10);
            list.add(str);
            System.out.println(list);
        }).start();
    }
}

结果:

Exception in thread "Thread-97" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1013)
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:967)
	at java.base/java.util.AbstractCollection.toString(AbstractCollection.java:456)
	at java.base/java.lang.String.valueOf(String.java:3365)
	at java.base/java.io.PrintStream.println(PrintStream.java:1047)
	at LeetCode.LeetCode.lambda$main$0(LeetCode.java:13)
	at java.base/java.lang.Thread.run(Thread.java:831)

为了确保集合对象在操作时是线程安全的,JUC中提供了很多能够解决操作时集合线程安全的措施。

线程安全 List

Vector

之前在学集合类时学过,Vector作为一种集合类,其中的操作都是线程安全的,但是并不推荐使用Vector,原因有以下几个方面。

  • Vector仅仅是将普通方法更改为了同步方法,虽然实现了线程安全但是也失去了很大一部分性能。
  • Vector是在JDK1.0中给出的类,随着新版本JDK的出现,有了更好的实现线程安全的替代类替代了Vector的地位,因此不推荐使用Vector

CopyOnWriteArrayList

简介

多线程之所以会引发错误,是因为多个线程修改数据时,不同的修改可能会覆盖其他线程的修改导致错误。CopyOnWriteArrayList通过写入时复制的策略确保数据修改时不会被覆盖。

CopyOnWriteArrayList所有可变操作都是对原始底层数组进行一次新的复制来实现,这样做使得CopyOnWriteArrayList适用的场景在读操作远远大于写操作的场景,比如缓存。同时CopyOnWriteArrayList中不存在扩容的概念,每一次写操作都是在原基础上复制一个副本,在副本上修改数据后改变Array引用。

缺点

1、可变操作( addsetremove ,等)是昂贵的,因为它们通常意味着复制整个底层数组。

2、不能用于实时读的场景,例如拷贝数组、新增元素都需要时间,所以调用一个set操作后,读取到的数据可能还是旧的,因此没法满足实时性的要求。

实现

add()

// lock 是一个Object 对象,作为一个全局的锁
public boolean add(E e) {
    synchronized (lock) {
        Object[] es = getArray();
        int len = es.length;
        // 写入时复制
        es = Arrays.copyOf(es, len + 1);
        es[len] = e;
        // 修改原始数据
        setArray(es);
        return true;
    }
}

set(int index,E e)

public E set(int index, E element) {
    synchronized (lock) {
        Object[] es = getArray();
        E oldValue = elementAt(es, index);
        if (oldValue != element) {
            // 写入时复制
            es = es.clone();
            es[index] = element;
        }
        // Ensure volatile write semantics even when oldvalue == element
        setArray(es);
        return oldValue;
    }
}

线程安全 Set

copyOnWriteArraySet

copyOnWriteArraySet是一个基于copyOnWriteArrayList的数据结构,相较于HashSet的底层是操作HashMap,该数据结构底层是操作copyOnwriteArrayList来存放数据,添加元素时通过调用indexOfRange来判断List中是否含有该元素,因此相较于HashSet该Set添加数据、contains查找数据是否存在的时间复杂度均是 O ( N ) O(N) O(N)

七、J.U.C 辅助工具

java.util.concurrent(J.U.C)大大提高了并发性能,AQS 被认为是 J.U.C 的核心。

CountDownLatch

用来控制一个或者多个线程等待多个线程。

维护了一个计数器 cnt,每次调用 countDown() 方法会让计数器的值减 1,减到 0 的时候,那些因为调用 await() 方法而在等待的线程就会被唤醒。


public class CountdownLatchExample {

    public static void main(String[] args) throws InterruptedException {
        final int totalThread = 10;
        // 给定等待总数
        CountDownLatch countDownLatch = new CountDownLatch(totalThread);
        ExecutorService executorService = Executors.newCachedThreadPool();
        for (int i = 0; i < totalThread; i++) {
            executorService.execute(() -> {
                System.out.print("run..");
                // 模拟一个线程执行完毕,countDown - 1 
                countDownLatch.countDown();
            });
        }
        // 等待countDownLatch为0时才能继续执行否则继续等待
        countDownLatch.await();
        System.out.println("end");
        executorService.shutdown();
    }
}
run..run..run..run..run..run..run..run..run..run..end

CyclicBarrier

[Cyclic] 循环

[Barrier] 障碍

用来控制多个线程互相等待,只有当多个线程都到达时,这些线程才会继续执行。

和 CountdownLatch 相似,都是通过维护计数器来实现的。线程执行 await() 方法之后计数器会减 1,并进行等待,直到计数器为 0,所有调用 await() 方法而在等待的线程才能继续执行。

CyclicBarrier 和 CountdownLatch 的一个区别是,CyclicBarrier 的计数器通过调用 reset() 方法可以循环使用,所以它才叫做循环屏障。

CyclicBarrier 有两个构造函数,其中 parties 指示计数器的初始值,barrierAction 在所有线程都到达屏障的时候会执行一次。

public CyclicBarrier(int parties, Runnable barrierAction) {
    if (parties <= 0) throw new IllegalArgumentException();
    this.parties = parties;
    this.count = parties;
    this.barrierCommand = barrierAction;
}

public CyclicBarrier(int parties) {
    this(parties, null);
}

public class CyclicBarrierExample {

    public static void main(String[] args) {
        final int totalThread = 10;
        CyclicBarrier cyclicBarrier = new CyclicBarrier(totalThread);
        ExecutorService executorService = Executors.newCachedThreadPool();
        for (int i = 0; i < totalThread; i++) {
            executorService.execute(() -> {
                System.out.print("before..");
                try {
                    cyclicBarrier.await();
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                }
                System.out.print("after..");
            });
        }
        executorService.shutdown();
    }
}
before..before..before..before..before..before..before..before..before..before..after..after..after..after..after..after..after..after..after..after..

Semaphore

[Semaphore] 信号量

Semaphore 类似于操作系统中的信号量,可以控制对互斥资源的访问线程数。

以下代码模拟了对某个服务的并发请求,每次只能有 3 个客户端同时访问,请求总数为 10。

acquire()提出请求,release结束请求,avalilablePermeits可用的访问数

public class SemaphoreExample {

    public static void main(String[] args) {
        final int clientCount = 3;
        final int totalRequestCount = 10;
        // 可用客户端总数
        Semaphore semaphore = new Semaphore(clientCount);
        ExecutorService executorService = Executors.newCachedThreadPool();
        // 共有请求10次
        for (int i = 0; i < totalRequestCount; i++) {
            executorService.execute(()->{
                try {
                    // 提出请求
                    semaphore.acquire();
                    System.out.print(semaphore.availablePermits() + " ");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    // 结束请求
                    semaphore.release();
                }
            });
        }
        executorService.shutdown();
    }
}
2 1 2 2 2 2 2 1 2 2

八、J.U.C - 其它组件

FutureTask

在介绍 Callable 时我们知道它可以有返回值,返回值通过 Future 进行封装。FutureTask 实现了 Runnable与Future 接口,该接口继承自 Runnable 和 Future 接口,这使得 FutureTask 既可以当做一个任务执行,也可以有返回值

public class FutureTask<V> implements RunnableFuture<V>
public interface RunnableFuture<V> extends Runnable, Future<V>

FutureTask 可用于异步获取执行结果或取消执行任务的场景。当一个计算任务需要执行很长时间,那么就可以用 FutureTask 来封装这个任务,主线程在完成自己的任务之后再去获取结果。

public class FutureTaskExample {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        FutureTask<Integer> futureTask = new FutureTask<Integer>(new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int result = 0;
                for (int i = 0; i < 100; i++) {
                    Thread.sleep(10);
                    result += i;
                }
                return result;
            }
        });

        Thread computeThread = new Thread(futureTask);
        computeThread.start();

        Thread otherThread = new Thread(() -> {
            System.out.println("other task is running...");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        otherThread.start();
        System.out.println(futureTask.get());
    }
}
other task is running...
4950

BlockingQueue

BlockingQueue是JUC为我们提供的阻塞队列接口,该接口的继承关系如下图所示:继承于Queue接口。

【Java 】JUC并发编程_第10张图片

那么什么是阻塞队列呢?

阻塞队列,顾名思义是一个队列的实现方式,同以往的队列区别在于“阻塞”二字。阻塞往往会发生在两种情况下。

1、阻塞队列长度总是固定的,存储的数据数达到了阻塞队列长度,该存储线程被阻塞。

2、阻塞队列中没有任何元素,无法取出数据,取数据的线程同样会被阻塞并等待执行。

通过阻塞能够更好配合多线程的使用,提高效率,因此阻塞队列通常使用在消息队列、线程池等技术中。

java.util.concurrent.BlockingQueue 接口有以下阻塞队列的实现:

  • FIFO 队列LinkedBlockingQueue(默认无限的长度)、ArrayBlockingQueue(固定长度)
  • 优先级队列PriorityBlockingQueue
  • 同步队列synchronizedQueue(最多允许有一个元素在队列中)

这些阻塞队列都提供了下面的几种方法:

方法 抛出异常 有返回值,不抛出异常 阻塞 等待执行 超时等待
添加元素 add() offer( ) 无时间参数 put() offer(, , ,) 有时间参数
删除元素 remove() poll() take() poll(, , ,)
检查队首元素 element() peek() ------------------- ------------------

使用 BlockingQueue 实现生产者消费者问题

public class ProducerConsumer {

    private static BlockingQueue<String> queue = new ArrayBlockingQueue<>(5);

    private static class Producer extends Thread {
        @Override
        public void run() {
            try {
                queue.put("product");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.print("produce..");
        }
    }

    private static class Consumer extends Thread {

        @Override
        public void run() {
            try {
                String product = queue.take();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.print("consume..");
        }
    }
}
public static void main(String[] args) {
    for (int i = 0; i < 2; i++) {
        Producer producer = new Producer();
        producer.start();
    }
    for (int i = 0; i < 5; i++) {
        Consumer consumer = new Consumer();
        consumer.start();
    }
    for (int i = 0; i < 3; i++) {
        Producer producer = new Producer();
        producer.start();
    }
}
produce..produce..consume..consume..produce..consume..produce..consume..produce..consume..

ForkJoin

主要用于并行计算中,和 MapReduce 原理类似,都是把大的计算任务拆分成多个小任务并行计算。

public class ForkJoinExample extends RecursiveTask<Integer> {

    private final int threshold = 5;
    private int first;
    private int last;

    public ForkJoinExample(int first, int last) {
        this.first = first;
        this.last = last;
    }

    @Override
    protected Integer compute() {
        int result = 0;
        if (last - first <= threshold) {
            // 任务足够小则直接计算
            for (int i = first; i <= last; i++) {
                result += i;
            }
        } else {
            // 拆分成小任务
            int middle = first + (last - first) / 2;
            ForkJoinExample leftTask = new ForkJoinExample(first, middle);
            ForkJoinExample rightTask = new ForkJoinExample(middle + 1, last);
            leftTask.fork();
            rightTask.fork();
            result = leftTask.join() + rightTask.join();
        }
        return result;
    }
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
    ForkJoinExample example = new ForkJoinExample(1, 10000);
    ForkJoinPool forkJoinPool = new ForkJoinPool();
    Future result = forkJoinPool.submit(example);
    System.out.println(result.get());
}

ForkJoin 使用 ForkJoinPool 来启动,它是一个特殊的线程池,线程数量取决于 CPU 核数。

public class ForkJoinPool extends AbstractExecutorService

ForkJoinPool 实现了工作窃取算法来提高 CPU 的利用率。每个线程都维护了一个双端队列,用来存储需要执行的任务。工作窃取算法允许空闲的线程从其它线程的双端队列中窃取一个任务来执行。窃取的任务必须是最晚的任务,避免和队列所属线程发生竞争。例如下图中,Thread2 从 Thread1 的队列中拿出最晚的 Task1 任务,Thread1 会拿出 Task2 来执行,这样就避免发生竞争。但是如果队列中只有一个任务时还是会发生竞争。


九、线程不安全示例

如果多个线程对同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的。

以下代码演示了 1000 个线程同时对 cnt 执行自增操作,操作结束之后它的值有可能小于 1000。

public class ThreadUnsafeExample {

    private int cnt = 0;

    public void add() {
        cnt++;
    }

    public int get() {
        return cnt;
    }
}
public static void main(String[] args) throws InterruptedException {
    final int threadSize = 1000;
    ThreadUnsafeExample example = new ThreadUnsafeExample();
    final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
    ExecutorService executorService = Executors.newCachedThreadPool();
    for (int i = 0; i < threadSize; i++) {
        executorService.execute(() -> {
            example.add();
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executorService.shutdown();
    System.out.println(example.get());
}
997

十、Java 内存模型

Java 内存模型试图屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。

主内存与工作内存

处理器上的寄存器的读写的速度比内存快几个数量级,为了解决这种速度矛盾,在它们之间加入了高速缓存。

加入高速缓存带来了一个新的问题:缓存一致性。如果多个缓存共享同一块主内存区域,那么多个缓存的数据可能会不一致,需要一些协议来解决这个问题。


所有的变量都存储在主内存中,每个线程还有自己的工作内存,工作内存存储在高速缓存或者寄存器中,保存了该线程使用的变量的主内存副本拷贝。

线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成。


内存间交互操作

Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作。


  • read:把一个变量的值从主内存传输到工作内存中
  • load:在 read 之后执行,把 read 得到的值放入工作内存的变量副本中
  • use:把工作内存中一个变量的值传递给执行引擎
  • assign:把一个从执行引擎接收到的值赋给工作内存的变量
  • store:把工作内存的一个变量的值传送到主内存中
  • write:在 store 之后执行,把 store 得到的值放入主内存的变量中
  • lock:作用于主内存的变量
  • unlock

内存模型三大特性

1. 原子性

Java 内存模型保证了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性,例如对一个 int 类型的变量执行 assign 赋值操作,这个操作就是原子性的。但是 Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行,即 load、store、read 和 write 操作可以不具备原子性。

有一个错误认识就是,int 等原子性的类型在多线程环境中不会出现线程安全问题。前面的线程不安全示例代码中,cnt 属于 int 类型变量,1000 个线程对它进行自增操作之后,得到的值为 997 而不是 1000。

为了方便讨论,将内存间的交互操作简化为 3 个:load、assign、store。

下图演示了两个线程同时对 cnt 进行操作,load、assign、store 这一系列操作整体上看不具备原子性,那么在 T1 修改 cnt 并且还没有将修改后的值写入主内存,T2 依然可以读入旧值。可以看出,这两个线程虽然执行了两次自增运算,但是主内存中 cnt 的值最后为 1 而不是 2。因此对 int 类型读写操作满足原子性只是说明 load、assign、store 这些单个操作具备原子性。


AtomicInteger 能保证多个线程修改的原子性。


使用 AtomicInteger 重写之前线程不安全的代码之后得到以下线程安全实现:

public class AtomicExample {
    private AtomicInteger cnt = new AtomicInteger();

    public void add() {
        cnt.incrementAndGet();
    }

    public int get() {
        return cnt.get();
    }
}
public static void main(String[] args) throws InterruptedException {
    final int threadSize = 1000;
    AtomicExample example = new AtomicExample(); // 只修改这条语句
    final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
    ExecutorService executorService = Executors.newCachedThreadPool();
    for (int i = 0; i < threadSize; i++) {
        executorService.execute(() -> {
            example.add();
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executorService.shutdown();
    System.out.println(example.get());
}
1000

除了使用原子类之外,也可以使用 synchronized 互斥锁来保证操作的原子性。它对应的内存间交互操作为:lock 和 unlock,在虚拟机实现上对应的字节码指令为 monitorenter 和 monitorexit。

public class AtomicSynchronizedExample {
    private int cnt = 0;

    public synchronized void add() {
        cnt++;
    }

    public synchronized int get() {
        return cnt;
    }
}
public static void main(String[] args) throws InterruptedException {
    final int threadSize = 1000;
    AtomicSynchronizedExample example = new AtomicSynchronizedExample();
    final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
    ExecutorService executorService = Executors.newCachedThreadPool();
    for (int i = 0; i < threadSize; i++) {
        executorService.execute(() -> {
            example.add();
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executorService.shutdown();
    System.out.println(example.get());
}
1000

2. 可见性

可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。

主要有三种实现可见性的方式:

  • volatile
  • synchronized,对一个变量执行 unlock 操作之前,必须把变量值同步回主内存。
  • final,被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。

对前面的线程不安全示例中的 cnt 变量使用 volatile 修饰,不能解决线程不安全问题,因为 volatile 并不能保证操作的原子性。

3. 有序性

有序性是指:在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序。在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

volatile 关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。

也可以通过 synchronized 来保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。

先行发生原则

上面提到了可以用 volatile 和 synchronized 来保证有序性。除此之外,JVM 还规定了先行发生原则,让一个操作无需控制就能先于另一个操作完成。

1. 单一线程原则

Single Thread rule

在一个线程内,在程序前面的操作先行发生于后面的操作。


2. 管程锁定规则

Monitor Lock Rule

一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。


3. volatile 变量规则

Volatile Variable Rule

对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。


4. 线程启动规则

Thread Start Rule

Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。


5. 线程加入规则

Thread Join Rule

Thread 对象的结束先行发生于 join() 方法返回。


6. 线程中断规则

Thread Interruption Rule

对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。

7. 对象终结规则

Finalizer Rule

一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

8. 传递性

Transitivity

如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。

十一、线程安全

多个线程不管以何种方式访问某个类,并且在主调代码中不需要进行同步,都能表现正确的行为。

线程安全有以下几种实现方式:

不可变

不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。多线程环境下,应当尽量使对象成为不可变,来满足线程安全。

不可变的类型:

  • final 关键字修饰的基本数据类型
  • String
  • 枚举类型
  • Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。

对于集合类型,可以使用 Collections.unmodifiableXXX() 方法来获取一个不可变的集合。

public class ImmutableExample {
    public static void main(String[] args) {
        Map<String, Integer> map = new HashMap<>();
        Map<String, Integer> unmodifiableMap = Collections.unmodifiableMap(map);
        unmodifiableMap.put("a", 1);
    }
}
Exception in thread "main" java.lang.UnsupportedOperationException
    at java.util.Collections$UnmodifiableMap.put(Collections.java:1457)
    at ImmutableExample.main(ImmutableExample.java:9)

Collections.unmodifiableXXX() 先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常。

public V put(K key, V value) {
    throw new UnsupportedOperationException();
}

互斥同步

synchronized 和 ReentrantLock。

非阻塞同步

互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。

互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。

1. CAS

乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是:比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。

2. AtomicInteger

J.U.C 包里面的整数原子类 AtomicInteger 的方法调用了 Unsafe 类的 CAS 操作。

以下代码使用了 AtomicInteger 执行了自增的操作。

private AtomicInteger cnt = new AtomicInteger();

public void add() {
    cnt.incrementAndGet();
}

以下代码是 incrementAndGet() 的源码,它调用了 Unsafe 的 getAndAddInt() 。

public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

以下代码是 getAndAddInt() 源码,var1 指示对象内存地址,var2 指示该字段相对对象内存地址的偏移,var4 指示操作需要加的数值,这里为 1。通过 getIntVolatile(var1, var2) 得到旧的预期值,通过调用 compareAndSwapInt() 来进行 CAS 比较,如果该字段内存地址中的值等于 var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。

可以看到 getAndAddInt() 在一个循环中进行,发生冲突的做法是不断的进行重试。

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

3. ABA

如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。

J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。

无同步方案

要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。

1. 栈封闭

多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。

public class StackClosedExample {
    public void add100() {
        int cnt = 0;
        for (int i = 0; i < 100; i++) {
            cnt++;
        }
        System.out.println(cnt);
    }
}
public static void main(String[] args) {
    StackClosedExample example = new StackClosedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> example.add100());
    executorService.execute(() -> example.add100());
    executorService.shutdown();
}
100
100

2. 线程本地存储(Thread Local Storage)

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。

可以使用 java.lang.ThreadLocal 类来实现线程本地存储功能。

对于以下代码,thread1 中设置 threadLocal 为 1,而 thread2 设置 threadLocal 为 2。过了一段时间之后,thread1 读取 threadLocal 依然是 1,不受 thread2 的影响。

public class ThreadLocalExample {
    public static void main(String[] args) {
        ThreadLocal threadLocal = new ThreadLocal();
        Thread thread1 = new Thread(() -> {
            threadLocal.set(1);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(threadLocal.get());
            threadLocal.remove();
        });
        Thread thread2 = new Thread(() -> {
            threadLocal.set(2);
            threadLocal.remove();
        });
        thread1.start();
        thread2.start();
    }
}
1

为了理解 ThreadLocal,先看以下代码:

public class ThreadLocalExample1 {
    public static void main(String[] args) {
        ThreadLocal threadLocal1 = new ThreadLocal();
        ThreadLocal threadLocal2 = new ThreadLocal();
        Thread thread1 = new Thread(() -> {
            threadLocal1.set(1);
            threadLocal2.set(1);
        });
        Thread thread2 = new Thread(() -> {
            threadLocal1.set(2);
            threadLocal2.set(2);
        });
        thread1.start();
        thread2.start();
    }
}

它所对应的底层结构图为:


每个 Thread 都有一个 ThreadLocal.ThreadLocalMap 对象。

/* ThreadLocal values pertaining to this thread. This map is maintained
 * by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

当调用一个 ThreadLocal 的 set(T value) 方法时,先得到当前线程的 ThreadLocalMap 对象,然后将 ThreadLocal->value 键值对插入到该 Map 中。

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

get() 方法类似。

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

ThreadLocal 从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争。

在一些场景 (尤其是使用线程池) 下,由于 ThreadLocal.ThreadLocalMap 的底层数据结构导致 ThreadLocal 有内存泄漏的情况,应该尽可能在每次使用 ThreadLocal 后手动调用 remove(),以避免出现 ThreadLocal 经典的内存泄漏甚至是造成自身业务混乱的风险。

3. 可重入代码(Reentrant Code)

这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。

可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。

十二、锁优化

这里的锁优化主要是指 JVM 对 synchronized 的优化。

自旋锁

互斥同步进入阻塞状态的开销都很大,应该尽量避免。在许多应用中,共享数据的锁定状态只会持续很短的一段时间。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。

自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。

在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。

锁消除

锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。

锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。

对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码就隐式加了锁:

public static String concatString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}

String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作:

public static String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

每个 append() 方法中都有一个同步块。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,因此可以进行消除。

锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。

上一节的示例代码中连续的 append() 方法就属于这类情况。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。

轻量级锁

JDK 1.6 引入了偏向锁和轻量级锁,从而让锁拥有了四个状态:无锁状态(unlocked)、偏向锁状态(biasble)、轻量级锁状态(lightweight locked)和重量级锁状态(inflated)。

以下是 HotSpot 虚拟机对象头的内存布局,这些数据被称为 Mark Word。其中 tag bits 对应了五个状态,这些状态在右侧的 state 表格中给出。除了 marked for gc 状态,其它四个状态已经在前面介绍过了。


下图左侧是一个线程的虚拟机栈,其中有一部分称为 Lock Record 的区域,这是在轻量级锁运行过程创建的,用于存放锁对象的 Mark Word。而右侧就是一个锁对象,包含了 Mark Word 和其它信息。


轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。

当尝试获取一个锁对象时,如果锁对象标记为 0 01,说明锁对象的锁未锁定(unlocked)状态。此时虚拟机在当前线程的虚拟机栈中创建 Lock Record,然后使用 CAS 操作将对象的 Mark Word 更新为 Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态。


如果 CAS 操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的虚拟机栈,如果是的话说明当前线程已经拥有了这个锁对象,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁。

偏向锁

偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。

当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。

当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。


十三、多线程开发良好的实践

  • 给线程起个有意义的名字,这样可以方便找 Bug。

  • 缩小同步范围,从而减少锁争用。例如对于 synchronized,应该尽量使用同步块而不是同步方法。

  • 多用同步工具少用 wait() 和 notify()。首先,CountDownLatch, CyclicBarrier, Semaphore 和 Exchanger 这些同步类简化了编码操作,而用 wait() 和 notify() 很难实现复杂控制流;其次,这些同步类是由最好的企业编写和维护,在后续的 JDK 中还会不断优化和完善。

  • 使用 BlockingQueue 实现生产者消费者问题。

  • 多用并发集合少用同步集合,例如应该使用 ConcurrentHashMap 而不是 Hashtable。

  • 使用本地变量和不可变类来保证线程安全。

  • 使用线程池而不是直接创建线程,这是因为创建线程代价很高,线程池可以有效地利用有限的线程来启动任务。

参考资料

  • BruceEckel. Java 编程思想: 第 4 版 [M]. 机械工业出版社, 2007.
  • 周志明. 深入理解 Java 虚拟机 [M]. 机械工业出版社, 2011.
  • Threads and Locks
  • 线程通信
  • Java 线程面试题 Top 50
  • BlockingQueue
  • thread state java
  • CSC 456 Spring 2012/ch7 MN
  • Java - Understanding Happens-before relationship
  • 6장 Thread Synchronization
  • How is Java’s ThreadLocal implemented under the hood?
  • Concurrent
  • JAVA FORK JOIN EXAMPLE
  • 聊聊并发(八)——Fork/Join 框架介绍
  • Eliminating SynchronizationRelated Atomic Operations with Biased Locking and Bulk Rebiasing

你可能感兴趣的:(Java基础,java,开发语言,后端,java高并发api)