1. 知识点思维导图

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

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

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的场景,假如生产者唤醒的也是生产者,后者发现缓冲区满重新进入阻塞,此时没有办法再唤醒在等待的消费者线程了,也会造成死锁。

扩展知识点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 节)
Java并发相关知识点梳理和研究-LMLPHP

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的构造方法。

线程工厂的作用

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

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详解

5. AQS解读

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

结构特点

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

6 JMM到底是个啥?

6.1 什么是偏序关系

假设 R 是集合 A 上的关系,如果R是自反的、反对称的和传递的,则称 R 是 A 上的一个偏序。偏序关系
那么,自反的、反对称的和传递的,又是什么?下面粘贴了百度百科相关词条:

  • 自反关系:设 R是 A上的一个二元关系,若对于 A中的每一个元素 a, (a,a)都属于 R,则称 R为自反关系。
  • 反对称关系:集合 A 上的二元关系 R 是反对称的,当且仅当对于X里的任意元素a, b,若a R-关系于 b 且 b R-关系于 a,则a=b。
  • 传递关系:令R是A上的二元关系,对于A中任意的 ,若 ,且 ,则 ,则称R具有传递性(或称R是传递关系)。

上面的反对称关系稍微不好理解,转换成逆否命题就好理解了:若a!=b,那么R中不能同存在aRb和bRa。

6.2 偏序关系和JMM

将R作为两个操作间的关系,集合A是所有操作的集合,那么就可以理解JMM为什么实际上是一套偏序关系了。

6.3 happens-before规则

这部分的说明很多文章都是有差异,比如锁原则,JLS(Java Language Specification,Java语言规范)特指的是监视器锁,只不过显式锁和内置锁有相同的内存语义而已。这里直接摘录原文并配上说明。原文见Chapter 17. Threads and Locks

试着翻译一下各项规则:
先定义hb(x, y)表示操作x和操作y的happens-before关系。

  1. 同一个线程的操作x, y,代码中顺序为x, y,那么hb(x, y)
  2. 对象构造方法要早于终结方法完成
  3. 如果x synchronizes-with y那么hb(x,y)
  4. 传递性,hb(x, y) 且hb(y,z)则hb(x,z)
  5. 同一个监视器锁解锁需要hb所有加锁(注:该规则扩展到显式锁)
  6. volatile的读hb所有写(该规则扩展到原子操作)
  7. 线程start() hb所有它的启动后的任何动作
  8. 线程中所有操作hb 对它的join()
  9. 对象默认构造器hb对它的读写

synchronizes-with又是啥?查阅了一下,表示”这个关系表示一个行为在发生时,它首先把要操作的那些对象同主存同步完毕之后才继续执行“。参考JMM(Java内存模型)中的核心概念
JLS上对happens-before的解释翻译过来还是不太好理解,《Java并发编程实战》的解释和Happens-beofre 先行发生原则(JVM 规范)一样,可以参考下。

最后可以发现,JMM只是一套规则,并没有提到具体的实现,程序员知道Java有这一重保证即可。

7. 短篇话题整理总结

7.1 ThreadLocal的用法总结

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

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

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

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虚拟机》第十三章的插图表现
Java并发相关知识点梳理和研究-LMLPHP
但是这个图没有体现轻量级锁释放后,仍可恢复为可偏向的。

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

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. 红绿灯路口

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

延伸阅读

什么是分布式锁
一文了解分布式锁

9. 未展开的话题

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

10. 其他参考

Java并发高频面试题

06-12 16:46