Java并发相关知识点梳理和研究

1. 知识点思维导图

(图比较大,可以右键在新窗口打开)

2. 经典的wait()/notify()/notifyAll()实现生产者/消费者编程范式深入分析 & synchronized

注:本节代码和部分分析参考了你真的懂wait、notify和notifyAll吗

看下面一段典型的wait()/notify()/notifyAll()代码,对于值得注意的细节,用注释标出。

import java.util.ArrayList;
import java.util.List;

public class Something {
    private Buffer mBuf = new Buffer(); // 共享的池子

    public void produce() {
        synchronized (this) { // 注1、注2
            while (mBuf.isFull()) { // 注3
                try {
                    wait(); // 注4
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            mBuf.add();
            notifyAll();  // 注5、注6
        }
    }

    public void consume() {
        synchronized (this) { // 见注1、注2
            while (mBuf.isEmpty()) { // 注3
                try {
                    wait(); // 注4
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            mBuf.remove();
            notifyAll(); // 注5、注6
        }
    }

    private class Buffer {
        private static final int MAX_CAPACITY = 1;
        private List innerList = new ArrayList<>(MAX_CAPACITY);

        void add() {
            if (isFull()) {
                throw new IndexOutOfBoundsException();
            } else {
                innerList.add(new Object());
            }
            System.out.println(Thread.currentThread().toString() + " add");

        }

        void remove() {
            if (isEmpty()) {
                throw new IndexOutOfBoundsException();
            } else {
                innerList.remove(MAX_CAPACITY - 1);
            }
            System.out.println(Thread.currentThread().toString() + " remove");
        }

        boolean isEmpty() {
            return innerList.isEmpty();
        }

        boolean isFull() {
            return innerList.size() == MAX_CAPACITY;
        }
    }

    public static void main(String[] args) {
        Something sth = new Something();
        Runnable runProduce = new Runnable() {
            int count = 4;

            @Override
            public void run() {
                while (count-- > 0) {
                    sth.produce();
                }
            }
        };
        Runnable runConsume = new Runnable() {
            int count = 4;

            @Override
            public void run() {
                while (count-- > 0) {
                    sth.consume();
                }
            }
        };
        for (int i = 0; i < 2; i++) {
            new Thread(runConsume).start();
        }
        for (int i = 0; i < 2; i++) {
            new Thread(runProduce).start();
        }
    }
}
  • 注1:wait()/notify()/notifyAll()必须在synchronized块中使用
  • 注2:使用synchronized(this)的原因是,这段代码的main(),是通过实例化Something的对象,并使用它的方法来进行生产/消费的,因此是一个指向this的对象锁。不同的场景,需要注意同步的对象的选择。
  • 注3:必须使用while循环来包裹wait()。设想一种场景:存在多个生产者或多个消费者消费者,以多个生成者为例,在缓冲区满的情况下,如果生产者通过notify()唤醒的线程仍是生产者,如果不使用while,那么获取锁的线程无法重新进入睡眠,锁也不能释放,造成死锁。
  • 注4:wait()会释放锁
  • 注5:notfiy()、notifyAll()会通知其他在wait的线程来获取锁,但是获取锁的真正时机是锁的原先持有者退出synchronized块的时候。
  • 注6:使用notifyAll()而不是notfiy()的原因是,仍考虑注3的场景,假如生产者唤醒的也是生产者,后者发现缓冲区满重新进入阻塞,此时没有办法再唤醒在等待的消费者线程了,也会造成死锁。
  • 注7:notfy()在JVM规范里是随机唤醒一个,但是在Hotspot里的实现是先进先出的 13.1 大佬问我: notify()是随机唤醒线程么?

扩展知识点1:synchronized块的两个队列

synchronized入口是将线程放入同步队列,wait()是将线程放入阻塞队列。notify()/notifyAll()实际上是把线程从阻塞队列放入同步队列。wait/notify/notifyAll方法需不需要被包含在synchronized块中,为什么?

扩展知识点2:synchronized重入原理

synchronized是可重入的,原理是它内部包含了一个计数器,进入时+1,退出时-1。 Java多线程:synchronized的可重入性

扩展知识点3:作用范围

synchronized支持三种用法:修饰静态方法、修饰实例方法、修饰代码块,前两种分别锁类对象、锁对象实例,最后一种根据传入的值来决定锁什么。
synchronized是基于java的对象头实现的,从字节码可以看出包括了一对进入&退出的监视器。
深入理解Java并发之synchronized实现原理

扩展知识点4:分布式环境synchronized的意义

单看应用所运行的的单个宿主机,仍然可能有多线程的处理模式,在这个前提下使用并发相关技术是必须的。

扩展知识点5:哪些方法释放资源,释放锁

所谓资源,指的是系统资源。

wait(): 线程进入阻塞状态,释放资源,释放锁,Object类final方法(notify/notifyAll一样,不可改写)。
sleep(): 线程进入阻塞态,释放资源,(如果在synchronized中)不释放锁,进入阻塞状态,唤醒随机线程,Thread类静态native方法。
yield(): 线程进入就绪态,释放资源,(如果在synchronized中)不释放锁,进入可执行状态,选择优先级高的线程执行,Thread类静态native方法。
如果线程产生的异常没有被捕获,会释放锁。
sleep和yield的比较

可以进一步地将阻塞划分为同步阻塞——进入synchronized时没获取到锁、等待阻塞——wait()、其他阻塞——sleep()/join(),可以参考线程的状态及sleep、wait等方法的区别

再进一步地,Java线程状态转移可以用下图表示(图源《Java 并发编程艺术》4.1.4 节)

WAITING状态的线程是不会消耗CPU资源的。

3. 线程数调优

理论篇

本节参考了《Java并发编程实战》8.2节,也可以结合面试问我,创建多少个线程合适?我该怎么说帮助理解,其中的计算题比较有价值。

前置知识

I/O密集型任务:I/O任务执行时CPU空闲。
CPU密集型任务:进行计算
有的任务是二者兼备的。为了便于分析,不考虑。

定性分析

场景:单核单线程/单核多线程/多核多线程。单核多线程+CPU密集型不能提升执行效率,多核+CPU密集型任务可以;单核多线程+I/O密集型可以提升执行效率。
因此,I/O耗时越多,线程也倾向于变多来充分利用IO等待时间。

定量分析

对于CPU密集型,线程数量=CPU 核数(逻辑)即可。特别的,为了防止线程在程序运行异常时不空转,额外多设一个线程线程数量 = CPU 核数(逻辑)+ 1
对于I/O密集型,最佳线程数 = CPU核数 * (1/CPU利用率) = CPU核数 * (1 + I/O耗时/CPU耗时)
为什么CPU利用率=1/(1+ I/O耗时/CPU耗时)?简单推导一下:

1/(1+ I/O耗时/CPU耗时) = 1/((CPU耗时+I/O耗时)/ CPU耗时) = CPU耗时/总耗时 = CPU利用率

如何获取参数——CPU利用率?

因为利用率不是一成不变的,需要通过全面的系统监控工具(如SkyWalking、CAT、zipkin),并长期进行调整观测。
可以先取2N即2倍核数,此时即假设I/O耗时/CPU耗时=1:1,再进行调优。

阿姆达尔定律

CPU并发处理时性能提升上限。
S=1/(1-a+a/n)
其中,a为并行计算部分所占比例,n为并行处理结点个数。
简单粗暴理解【阿姆达尔定律】

Java线程池篇

基本属性

/**
 * 使用给定的初始参数和默认线程工厂创建一个新的ThreadPoolExecutor ,并拒绝执行处理程序。 使用Executors工厂方法之一可能更方便,而不是这种通用构造函数。
参数
 *  corePoolSize - 即使空闲时仍保留在池中的线程数,除非设置 allowCoreThreadTimeOut
 *  maximumPoolSize - 池中允许的最大线程数
 *  keepAliveTime - 当线程数大于核心时,这是多余的空闲线程在终止之前等待新任务的最大时间。
 *  unit - keepAliveTime参数的时间单位
 *  workQueue - 在执行任务之前用于保存任务的队列。 该队列将仅保存execute方法提交的Runnable任务。
 * threadFactory - 执行程序创建新线程时使用的工厂
 */
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory)

常见线程池

由java.util.concurrent.Executors创建的线程池比较常用,而不是使用ThreadPoolExecutor的构造方法。
注意:有的不带参数的方法,默认最大大小是Integer.MAX_VALUE,可能导致资源耗尽,尽量不要用。

名称 特性 corePoolSize maximumPoolSize keepAliveTime + unit workQueue threadFactory
newFixedThreadPool 线程池大小为固定值 nThreads nThreads 0 ms LinkedBlockingQueue DefaultThreadFactory
newSingleThreadExecutor 线程池大小固定为1 1 1 0 ms LinkedBlockingQueue DefaultThreadFactory
newCachedThreadPool 0 Integer.MAX_VALUE 60L s SynchronousQueue DefaultThreadFactory
newScheduledThreadPool 延迟执行任务或按周期重复执行任务 0 Integer.MAX_VALUE 0 ns DelayedWorkQueue DefaultThreadFactory

workQueue分析:

  • SynchronousQueue 不“直接”持有元素,而是持有线程,让生产者线程和消费者线程配对,无法配对的就会阻塞

    • 支持先进先出和先进后出(栈)两种模式
  • LinkedBlockingQueue 缓冲队列

    • 不设置容量则为Integer.MAX_VALUE
    • put和get各有一个非公平ReentrantLock和对应的Condition
  • DelayedWorkQueue

    • ScheduledThreadPoolExecutor的静态内部类,Worker会不断地从这里取任务执行
    • 堆,如果未到时间,poll()返回null,这样Worker就不会执行了
    • 新插入的元素是堆头时会通过Condition avaliable唤醒等待的线程。这个avaliable保证线程不会无限循环,而是阻塞在锁这里。
  • ArrayBlockingQueue 缓冲数组队列

    • 只有一个ReentrantLock和两个Condition,因此不能put和get并行
    • 可设置公平和非公平
    • 必须指定大小

其他:
ArrayBlockingQueue

线程工厂的作用

用来创建线程,统一在创建线程时设置一些参数,如是否守护线程。线程一些特性等,如优先级。
可参考004-多线程-JUC线程池-ThreadFactory线程工厂

多线程的异常处理

子线程的异常,主线程不会处理。默认只会将子线程的堆栈打印到console上,这一步也不是主线程做的。
如果想要处理,可以通过以下方式:

  • 设置线程的异常处理器即Thread.setDefaultUncaughtExceptionHandler()。线程池下可以用线程工厂批量设置。
  • extends线程池,重写afterExecuteafterExecute(Runnable r, Throwable t)这个方法。默认是空的
  • 使用future,在get()时处理。但是get()只会抛出InterruptedException、ExecutionException这两种异常。

4. 并发容器相关

并发容器可以说是一个面试时的高频问题了,网络上也有很多介绍,这里就不重复解读,将相关的知识整理一下,边看源码边读文章效果会很好。
先提一句,Vector是线程安全的,为啥现在不推荐用呢?看源码可以知道,它将大部分方法都加了synchronized,牺牲了性能换取线程安全,是不可取的。如果真的有需要线程安全的容器,可以用Collections.synchronizedList()来手动给list加synchronized。
再补充一句,其实Vector和Collections.synchronizedList()使用复合操作或迭代器Iterator时也不是线程安全的,具体解释会在下一篇博客Java容器中介绍。

ConcurrentHashMap

先重点介绍Map的两个实现类HashMap和ConcurrentHashMap

 public final boolean equals(Object o) {
     if (o == this)
         return true;
     if (o instanceof Map.Entry) {
         Map.Entry<?,?> e = (Map.Entry<?,?>)o;
         if (Objects.equals(key, e.getKey()) &&
             Objects.equals(value, e.getValue()))
             return true;
     }
     return false;
 }

ConcurrentLinkedQueue

ConcurrentLinkedQueue使用CAS无锁操作,保证入队出队的线程安全,但不保证遍历时的线程安全。遍历要想线程安全需要单独加锁。
由于算法的特性,这个容器的尾结点是有延迟的,tail不一定是尾节点,但p.next == null的节点一定是尾结点。
入队出队操作很抽象,需要画图帮助理解源码,对应的源码分析可参考并发容器-ConcurrentLinkedQueue详解

CopyOnWriteArrayList和CopyOnWriteArraySet

简单来说,CopyOnWriteArrayList底层是一个volatile的数组加一个ReentrantLock,所有写操作(add、set等)都会先锁,处理时copy一个新数组出来,处理完用新数组替换掉原来持有的老数组,这样确保读是可以同时进行的(读的是老数据)。
这样做确保写操作是线程安全的,而读操作是不需要锁的。
相比普通的ArrayList,读性能差不多,写的性能自然会差一点。

(其实之前的项目中,热更新配置里我的做法是类似的,监听到配置中心的热更新事件时,先将配置组装好,再替换掉原来配置对象的引用。)

CopyOnWriteArraySet底层是一个CopyOnWriteArrayList,只有在元素不存在时才会插入。

可以参考:
https://www.cnblogs.com/skywang12345/p/3498483.html#p1
https://www.cnblogs.com/skywang12345/p/3498497.html

5. AQS解读

抽象队列同步器AbstractQueuedSynchronizer(AQS)是JUC中很多并发工具类的基础,用来抽象各种并发控制行为,如ReentranLock、Semaphore。顺便一提,线程池的工作线程模型Worker也实现了AQS。
之前试着直接读源码,效果不太好,还是建议结合质量较高的文章来读,这里推荐一篇:Java并发之AQS详解,并且作者还在不断更新。
这里简单记录一下总结的点。

结构特点

  • volatile int state标记位,标识当前的同步状态。具体的用法和使用AQS的工具类有关。同时,在做CAS的时候,state的状态变更是通过计算该变量在对象的偏移量来设置的。
  • CLH队列。CLH锁(Craig,Landin andHagersten)是一种在SMP(Symmetric Multi-Processor对称多处理器)架构下基于单链表的高性能的自旋锁,队列中每个节点代表一个自旋的线程,每个线程只需在代表前一个线程的节点上的布尔值locked自旋即可,如图

    图源和CLH的详解见算法:CLH锁的原理及实现
  • exclusiveOwnerThread独占模式的拥有者,记录现在是哪个线程占用这个AQS。

操作特点

  • 对state使用>0和<0的判断,初看代码很难看懂,这么写的原因是负值表示结点处于有效等待状态,而正值表示结点已被取消
  • 大量的CAS:无论是获取锁、入队、获取锁失败后的自旋,全部是依赖CAS实现的。
  • 没有使用synchronized:不难理解,如果使用了同步块,那么其实现ReentranLock就没有和synchronized比较的价值了。不过这一点很少有文章专门提到。
  • LockSupport类的unpark()/park()方法的使用:回忆上文提到的线程状态,如果线程获取不到AQS控制的资源,需要将线程置于waiting,对应可选的方法是wait()/join()/park()。在AQS这个场景下,显然一没有synchronized,二没有显式的在同一个代码块中用join处理多线程(借助队列来处理线程,线程相互之间不感知),那么只有park()才能达到目的。

处理流程

获取资源acquire(int)

  1. 尝试获取资源(改写state),成功则返回
  2. CAS(失败则自旋)加入等待队列队尾
  3. 在队列中自旋,尝试获取一次资源(前提:队头+ tryAcquire()成功),每次失败都会更改线程状态为waiting。自旋时会看看前驱有没有失效的节点(即不再请求资源的),如果有就插队到最前面并把前面无效节点清理掉便于gc
  4. waiting状态中不响应中断,获取资源后才会补一个自我中断selfInterrupt (调用Thread.currentThread().interrupt())

释放资源release(int)

  1. 尝试释放,成功则处理后续动作,失败直接返回false
  2. 唤醒(unpark)等待队列的下一个线程。如果当前节点没找到后继,则从队尾tail从后往前找。

共享模式获取资源acquireShared(int)

除了抽象方法tryAcquireShared()以外,基本和acquire(int)一致。
在等待队列中获取资源后,会调用独有的setHeadAndPropagate()方法,将这个节点设为头结点的同时,检查后续节点是否可以获取资源。

共享模式释放资源releaseShared()

和release(int)区别在于,唤醒后继时,不要求当前线程节点状态为0。举例:当前线程A原先拥有5个资源,释放1个,后继的等待线程B刚好需要1个,那么此时A、B就可以并行了。

未实现的方法

为了便于使用AQS的类更加个性化,AQS有一下方法直接抛UnsupportedOperationException。

  • isHeldExclusively()
  • tryAcquire()
  • tryRelease()
  • tryAcquireShared()
  • tryReleaseShared()
    不写成abstract方法的原因是,避免强迫不需要对应方法的类实现这些方法。比如要写一个独占的锁,那么就不需要实现共享模式的方法。

AQS小结

读完源码总结一下,AQS是一个维护资源和请求资源的线程之间的关系的队列。对于资源(有序或无序的)获取和释放已经提取成了线程的出入队方法,这个队列同时维护上线程的自旋状态和管理线程间的睡眠唤醒。

应用

本节可以看作为《JAVA并发变成实战》14.6的引申。

ReentrantLock

用内部类Sync实现AQS,Sync实现ReentrantLock的行为。Sync又有FairSync和UnfairSync两种实现。FairSync,lock对应aquire(1);UnfairSync,lock先CAS试着获取一次,不行再aquire(1)。
实际上,ReentrantLock的公平/非公平锁只在首次lock时有区别,入队后唤醒仍是按顺序的。可以参考reentrantLock公平锁和非公平锁源码解析
Sync只实现了独占模式。

注意:CyclicBarrier直接用了ReentrantLock,没有直接用AQS。

Semaphore

和ReentrantLock类似,Semaphore也有一个内部类Sync,但相反的是这个Sync只实现了共享模式的acquire()/release()。
Semaphore在acquire()/release()时会计算资源余量并设置,其中unfair模式下的acquire会无条件自旋CAS,fair模式下只有在AQS里不存在排队中的后继的情况下才会CAS,否则自旋。

CountDownLatch

同样有一个内部类Sync,但是不再区分fair/unfair,并且是共享模式的。
await()调用的是acquireSharedInterruptibly(),自然也存在自旋的可能,只是编程时一般不这么用。countDown()时释放一个资源继续在releaseShared()里自旋直到全部释放。

FutureTask

新版的FutureTask已经重写,不再使用AQS,这里就不再提了。

ReentrantReadWriteLock

可重入读写锁,涉及到锁升级,这里没有研究的很透彻,有兴趣可以自行了解。
注意到读锁和写锁是共用同一个Sync的。

线程池的Worker

Worker本身也是Runable,同时也利用AQS获取锁的逻辑。

6 JMM到底是个啥?

(这部分202408进行了重写)
即Java内存模型(Java Memory Model),是Java语言规范的一部分,它定义了Java程序中各种变量(线程共享变量)的访问规则,以及在并发环境下,这些变量的读写操作如何保证原子性、可见性和有序性。JMM主要解决以下几个关键问题:

  • 原子性:确保复合操作(如自增操作)在多线程环境中作为一个不可分割的单元执行,要么全部执行,要么全部不执行。
  • 可见性:确保当一个线程修改了线程共享变量的值,其他线程能够立即看到这个修改。
  • 有序性:确保在单线程环境中,代码的执行顺序与编写顺序一致;在多线程环境中,通过happens-before原则来保证操作的相对顺序。
  • happens-before原则:定义了两个操作之间的因果关系,如果操作A happens-before 操作B,那么A的结果对B可见。
  • 内存屏障:通过内存屏障来控制指令的执行顺序,防止编译器和处理器对内存操作进行不合理的重排序。实现方式在不同平台不同,比如Java的volatile、某些CPU指令等。内存屏障又分为:
    • 读屏障:确保所有在读屏障之前的读操作完成后,才能执行读屏障之后的读操作。
    • 写屏障:确保所有在写屏障之前的写操作完成后,才能执行写屏障之后的写操作。
    • 全屏障:同时具有读屏障和写屏障的功能,确保所有在全屏障之前的读写操作完成后,才能执行全屏障之后的读写操作。

happens-before

happens-before原则是Java内存模型(JMM)中的一个核心概念,它定义了在多线程环境中,一个操作何时对另一个操作可见。简单来说,如果一个操作A happens-before 另一个操作B,那么A的结果对B必须是可见的。这意味着:

  1. 数据一致性:在多线程环境中,当一个线程修改了共享变量的值,并且这个修改对其他线程可见,那么其他线程在执行后续操作时必须看到这个修改后的值。

  2. 操作顺序:happens-before原则确保了在程序的执行中,某些操作的顺序性。例如,在一个线程中,对共享变量的写操作必须在对该变量的读操作之前完成。

happens-before原则的常见情况包括:

  • 程序顺序规则:在单个线程内,按照代码的书写顺序执行操作。
  • 监视器锁规则:对同一个监视器锁的解锁操作happens-before于随后对这个监视器锁的加锁操作。
  • volatile变量规则:对volatile变量的写操作happens-before于后续对这个变量的读操作。
  • 线程启动规则:线程的start()方法happens-before于线程中的任何动作。
  • 线程终止规则:线程中的任何动作都happens-before于其他线程检测到该线程的终止。
  • 中断规则:线程的interrupt()方法happens-before于检测到中断的代码。
  • 对象final字段规则:被声明为final的字段,在构造函数完成后,其值对于其他线程是可见的。

happens-before原则是JMM用来防止编译器和处理器对代码进行重排序的一种机制,它确保了在多线程环境中,即使代码被重排序,也不会破坏程序的内存一致性。理解并正确应用happens-before原则对于编写正确、高效的并发程序至关重要。

既然有JMM,为什么volatile的一项功能是禁止指令重排序?

volatile除了禁止指令重排序外还有其它功能:多线程环境下的可见性、防止内存屏障优化等,具有更强的功能。

7. 短篇话题整理总结

7.1 ThreadLocal的用法总结

应用场景:在多线程下替代类的静态变量(static),在多线程环境进行单个 的数据隔离。

为什么推荐使用static修饰ThreadLocal?

这时才能保证"一个线程,一个ThreadLocal",否则便成了“一个线程,(多个对象实例时)多个ThreadLocal”。
可能会有内存泄漏:ThreadLocalMap的Entry是弱引用,但指向的对象value不是,如果Entry被回收,value还在。解法是手动remove掉。
(本节参考了《Java并发编程实战》)

引申话题:gc时ThreadLocal的弱引用被回收,是否会造成程序bug?

答案是可以避免。
先回顾下各种引用 Java:强引用,软引用,弱引用和虚引用
然后再结合场景分析JAVA并发(3)—线程运行时发生GC,会回收ThreadLocal弱引用的key吗?

7.2 CountDownLatch和CyclicBarrier区别

https://blog.csdn.net/tolcf/article/details/50925145
CountDownLatch的子任务调用countDown后会继续执行直至该线程结束。
CyclicBarrier的子任务await时会暂停执行;可重复使用,即await的数目达到设置的值时,唤醒所有await的线程进行下一轮。

7.3 ReentrantLock用了CAS但为什么不是乐观锁?

https://blog.csdn.net/qq_35688140/article/details/101223701
我的看法:因为仍有可能造成阻塞,而乐观锁更新失败则会直接返回(CAS允许自旋)。
换一个角度,悲观锁是预先做最坏的设想——一定会有其他任务并发,那么就先占好坑再更新;乐观锁则是认为不一定有并发,更新时判断再是否有问题。这样看来ReentrantLock从使用方式上来说是悲观锁。

7.4 双重检查加锁

public classDoubleCheckedLocking{ //1
      private static Instance instance; //2
      public staticI nstance getInstance(){ //3
            if(instance==null){ //4:第一次检查
                  synchronized(DoubleCheckedLocking.class){ //5:加锁
                        if(instance==null) //6:第二次检查
                              instance=newInstance(); //7:问题的根源出在这里
                  } //8
            }//9
            return instance;
      }
}

问题

一个线程看到另一个线程初始化该类的部分构造的对象,即以上代码注释第4处这里读到非null但未完全初始化

原因

注释第7处,创建对象实例的三步指令1.分配内存空间2.初始化3.引用指向分配的地址,2和3可能重排序

解决

方案1,给instance加violatile,确保其他线程看到的是初始化完成的对象
方案2,使用占位类,在类初始化时初始化对象,如下(实际上饿汉模式都可以解决)

public class InstanceFactory {
      private static class InstanceHolder{
            public static Instance instance= newInstance();
      }
      public static Instance getInstance() {
            return InstanceHolder.instance;  //这里将导致InstanceHolder类被初始化
      }
}

7.5 FutureTask

FutureTask是Future的实现类,可以使用Future来接收线程池的submit()方法,也可以直接用FutureTask封装任务,作为submit()的参数。具体的用法可以参考Java并发编程:Callable、Future和FutureTask
新版的FutureTask不再使用AQS。
FutureTask设置了当前工作线程,对于其任务维护了一个内部状态转换状态机,通过CAS做状态判断和转换。
当其他线程来get()时,如果任务未完成则放入等待队列,自旋直到取到结果(for循环+LockSupport.park()),否则直接取结果。
具体实现原理可以参考《线程池系列一》-FutureTask原理讲解与源码剖析

7.6 JDK1.6锁优化之轻量级锁和偏向锁

实际上二者是有联系的,都是基于mark word实现。这个转换关系可以用《深入理解Java虚拟机》第十三章的插图表现

但是这个图没有体现轻量级锁释放后,仍可恢复为可偏向的。

7.7 问题排查三板斧

  1. top查看内存占用率,-H可以看线程(不会完整展示),-p [pid]看指定进程的线程
    注意:linux线程和进程id都是在pid这一列展示的。
  2. pstack跟踪进程栈,strace查看进程的系统操作。多次执行pstack来观察进程是不是总是处于某种上下文中。
  3. jps直接获取java进程id,jstat看java进程情况。jstate可用不同的参数来查看不同纬度的信息:类加载情况、gc统计、堆内存统计、新生代/老年代内存统计等,具体可以参考【JVM】jstat命令详解---JVM的统计监测工具
  4. jstack打印java线程堆栈,和pstack展示方式很像,是java纬度的
  5. jmap打印java内存情况,-dump可以生成dump文件
  6. 分析dump文件,如MAT

7.8 Thread.join()原理

根据源码可以发现,join()实质是判断对应线程是否还在存活,如果还在存活会一直空转(指定了超时时间则只空转这么久)。
再加上注释:线程终结时会notifyAll(),唤醒在join()中调用wait()的等待中的线程。
同时,这里是需要synchronized的。

public final synchronized void join(long millis) throws InterruptedException {
        long base = System.currentTimeMillis();
        long now = 0;

        if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (millis == 0) {
            while (isAlive()) {  // isAlive是native方法
                wait(0);
            }
        } else {
            while (isAlive()) {
                long delay = millis - now;
                if (delay <= 0) {
                    break;
                }
                wait(delay);
                now = System.currentTimeMillis() - base;
            }
        }
    }

只看源码,是有可能在调用join()前线程就完结的。

7.9 Future和FutureTask

  • Future是一个(将来的)执行结果的抽象封装,可以通过接口判断是否执行完成、取得执行结果。持有Future的引用,在编码过程中会清晰一些,不需要关心具体执行的对象(如线程池)的状态,也不需要使用具体执行对象的API来获取结果。
  • FutureTask是RunnableFuture的实现,RunnableFuture继承了Future和Runnable,封装了执行内容(一个Callable)、执行状态、执行结果,需要手动启动。
  • CompletableFuture是JDK1.8新增的,用以支持函数式编程下stream并发处理流式数据的场景,内部默认使用ForkJoinPool。可以阅读实践:使用了CompletableFuture之后,程序性能提升了三倍的示例代码及CompletableFuture基本用法

关于ForkJoinPool,又可以单独写文章了,请自行了解。

7.10 Runable和Callable的区别

(本节由kimi生成)
CallableRunnable 都是 Java 中用于创建线程的接口,但它们之间存在一些关键的区别:

  1. 返回值

    • Callable 接口的实现可以返回一个类型为 V 的泛型返回值,通过 Future 接口获取。
    • Runnable 接口不接受返回值。
  2. 异常

    • Callable 可以抛出 checked 或 unchecked 异常。
    • Runnable 只能抛出 unchecked 异常(即 RuntimeException 或其子类)。
  3. 方法签名

    • Callable 接口的 call() 方法声明了返回类型 V
    • Runnable 接口的 run() 方法没有返回类型,即 void
  4. Future 接口

    • Callable 任务可以通过 ExecutorService 提交,返回一个实现了 Future 接口的对象,允许调用者获取任务的结果或取消任务。
    • Runnable 任务没有返回 Future 对象,因此不能获取任务的结果或取消任务。
  5. 用法

    • Callable 适用于需要执行后返回结果的任务。
    • Runnable 适用于不需要返回结果的后台任务。
  6. 实现方式

    • 实现 Callable 接口的类必须实现 call() 方法。
    • 实现 Runnable 接口的类必须实现 run() 方法。
  7. 线程执行模型

    • Callable 任务可以与 Future 结合使用,提供了一种异步执行任务的方式,可以在任务执行完毕后获取结果。
    • Runnable 任务通常用于简单的线程执行,不提供结果获取或任务取消的功能。

以下是 CallableRunnable 的简单示例:

// Runnable 示例
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        // 执行一些操作
    }
};

// Callable 示例
Callable<Integer> callable = new Callable<Integer>() {
    @Override
    public Integer call() throws Exception {
        // 执行一些操作并返回结果
        return 123;
    }
};

在实际应用中,选择 Callable 还是 Runnable 取决于任务是否需要返回结果以及是否需要异常处理。如果任务需要返回结果或抛出异常,Callable 是更好的选择;如果任务不需要返回结果,可以使用 Runnable

在Java中,CallableRunnable接口都是通过线程池(ExecutorService)来管理任务的提交和执行的。尽管它们在功能上有所不同,但线程池对它们的管理方式有相似之处。以下是CallableRunnable在线程池中的管理方式:

  1. 任务提交

    • Callable任务通过ExecutorServicesubmit(Callable<T> task)方法提交,返回一个Future<T>对象。
    • Runnable任务通过ExecutorServicesubmit(Runnable task)execute(Runnable command)方法提交,返回一个Future<?>对象。
  2. 任务调度

    • 线程池接收到任务提交请求后,会根据其内部的调度算法将任务分配给线程池中的一个线程。
  3. 任务执行

    • 对于Callable任务,线程池中的线程会调用Callable对象的call()方法,并处理其返回结果或抛出的异常。
    • 对于Runnable任务,线程会调用Runnable对象的run()方法执行任务。
  4. 任务结果处理

    • Callable任务的结果可以通过返回的Future对象获取,支持取消任务、查询任务状态、等待任务完成等操作。
    • Runnable任务由于没有返回值,其Future对象主要用于任务取消和查询状态。
  5. 异常处理

    • Callable允许捕获和抛出异常,包括受检异常和非受检异常。异常可以在Future对象上通过get()方法被捕获。
    • Runnable只能抛出非受检异常(RuntimeException及其子类)。如果需要处理异常,必须在Runnable内部捕获并处理。
  6. 生命周期管理

    • 线程池负责管理任务的生命周期,包括任务的排队、执行、完成或取消。
  7. 线程重用

    • 线程池中的线程在执行完一个任务后,不会立即销毁,而是可以被重用来执行其他任务。
  8. 关闭线程池

    • 当不再需要线程池时,可以调用shutdown()shutdownNow()方法来关闭线程池,等待任务完成或尝试停止正在执行的任务。

以下是使用线程池提交CallableRunnable任务的示例:

ExecutorService executorService = Executors.newFixedThreadPool(10);

// 提交Callable任务
Future<Integer> future = executorService.submit(new Callable<Integer>() {
    @Override
    public Integer call() throws Exception {
        // 执行一些操作并返回结果
        return 123;
    }
});

// 提交Runnable任务
executorService.submit(new Runnable() {
    @Override
    public void run() {
        // 执行一些操作
    }
});

// 关闭线程池
executorService.shutdown();

8. LeetCode多线程习题

原题目和详解参考Concurrency - 力扣

1114.按序打印

按照指定次序完成一系列动作,可以看做是buffer为1的1对1生产者消费者模型。

1115.交替打印FooBar

交替执行(不完全是生产者-消费者模型)某些动作。
可用的解法:

  • synchronized
  • Semaphore
  • CountDownLatch
  • CyclicBarrier
  • Lock

1116.打印零与奇偶数:0102...

和1114类似

1188. 设计有限阻塞队列

注意: 使用synchronize解法时,wait()应置于while中循环判断.
如果只用if,唤醒后不再次判断dequeue可能NPE
本题可以加深理解为什么要用while

1195. 交替打印字符串

根据AC的解法推断, 每个线程只调用对应方法一次,因此需要在方法内部循环
不推荐只用synchronized,四个线程按顺序打印, 如果使用单一的锁很容易饥饿导致超时

推荐解法:
AtomicInteger无锁解法
CylicBarrier高效解法
Semaphore加锁

1279. 红绿灯路口

题目难懂,暗含条件:车来时红绿灯不是绿的,则强制变绿通过。红绿灯本身的时间没有严格控制

延伸阅读

什么是分布式锁
一文了解分布式锁
ThreadPoolExcutor 原理探究

9. 未展开的话题

并发研究之CPU缓存一致性协议(MESI)
线程池原理(四):ScheduledThreadPoolExecutor
一半是天使一半是魔鬼的Unsafe类详解 —— unsafe类都有什么?用偏移量直接访问、线程操作、内存管理和内存屏障、CAS

10. 其他参考

Java并发高频面试题

posted @ 2020-06-12 02:14  五岳  阅读(668)  评论(0编辑  收藏  举报
回到顶部