Java 并发编程

Java 并发编程

    • 多线程基础
      • 为什么会有多线程?
      • CPU架构
      • Java 线程的创建过程
      • 线程和进程的区别是什么?
    • Java多线程
      • 守护线程
      • Runnable
      • 线程状态
      • Thread类
      • wait & notify
      • Thread 的状态改变操作
      • Thread 的中断与异常处理
      • Thread 状态
    • 线程安全
      • 多线程执行会遇到什么问题?
      • 并发相关的性质
        • 原子性
        • 可见性
        • 有序性
      • synchronized 的实现
        • 加在方法上加在代码块上,使用的单独的锁对象有什么区别?
        • 偏向锁
        • 轻量级锁
        • 重量级锁
        • GC标记
      • volatile
      • final
        • 常量和变量有什么区别?
    • 线程池原理与使用
      • 线程池
      • Executor – 执行者
      • ExecutorService
        • 优雅停机
      • ThreadPoolExecutor
        • 线程池最小、最大核心数、缓冲队列的设计逻辑
      • 线程池参数
        • 缓冲队列
        • 拒绝策略
        • ThreadFactory
      • ThreadPoolExecutor
      • 创建线程池方法
      • 创建固定线程池的经验
      • Callable – 线程池基础接口
      • Future – 线程池基础接口
    • Java 并发包 (JUC)
      • JDK 类库
        • JDK 核心库的包
        • java.util.concurrency
    • 锁的原理
      • 为什么需要显式的 Lock
        • synchronized 加锁的缺点
      • 更自由的锁: Lock
      • 基础接口 - Lock
        • 什么是可重入锁?
        • 什么是公平锁?
      • ReadWriteLock(读写锁) – 接口与实现
      • 基础接口 - Condition
      • LockSupport--锁当前线程
      • 用锁的最佳实践
    • 并发原子类
      • Atomic 工具类
      • 无锁技术 – Atomic 工具类
      • 锁与无锁之争
        • 到底是有锁好,还是无锁好?
      • LongAdder 对 AtomicLong 的改进
    • 并发工具类
      • 什么是并发工具类
      • AQS(抽象队列式的同步器)
      • Semaphore - 信号量
      • CountdownLatch
      • CyclicBarrier
      • CountDownLatch 与 CyclicBarrier 比较
      • Future/FutureTask/CompletableFuture
    • 常用线程安全类型
      • ArrayList
        • transient 关键字
        • 移位运算符
      • LinkedList
      • List线程安全的简单办法
      • CopyOnWriteArrayList
      • HashMap
      • LinkedHashMap
      • ConcurrentHashMap-Java7 分段锁
      • ConcurrentHashMap-Java8
      • 并发集合类总结
    • 并发编程
      • 线程安全操作利器 - ThreadLocal
      • 四两拨千斤 - 并行 Stream
      • 伪并发问题
      • 分布式下的锁和计数器
    • 经验总结
      • 加锁需要考虑的问题
      • 线程间协作与通信
      • 不同进程之间有哪些方式通信?
    • 并发编程常见面试题

多线程基础

为什么会有多线程?

本质原因是摩尔定律失效 -> 多核 + 分布式时代的来临。

在单个服务器里面,增加更多的CPU。在整个集群里面,增加更多的PC机器。

摩尔定律的失效也导致了JVM、NIO技术变得更复杂。

CPU架构

在单核的CPU上,不管起多少个线程,在一个时间点都只有一个线程是在RUN的。在CPU上,把整个事件划成时间片,然后所有的线程再去抢这些时间片。每个时间点一个CPU,它只有一个时间片能够被这些线程抢到。谁抢到谁运行。

SMP架构和NUMA架构
SMP 架构是CPU共同竞争一块共享内存,架构不容易扩展,竞争非常激烈,总线上传输的数据量也特别大。

采用分区和分治的办法改进。

NUMA 架构是把内存分为不同的块,其中部分CPU共享对应的内存块。在同一时间下一块内存只有少量的CPU在上面访问读写操作,极大的减少了CPU对内存的竞争。这个架构也适合做扩容。如果有少量的数据需要跨CPU和内存,那么就通过中间的这个Router进行少量数据的交互。

多 CPU 核心意味着同时操作系统有更多的
并行计算资源可以使用。
操作系统以线程作为基本的调度单元。

单线程是最好处理不过的。
线程越多,管理复杂度越高。

跟我们程序员都喜欢单干一样。《人月神话》里说加人可能干的更慢。

Java 线程的创建过程

Java 线程的创建过程
我们可以在Java里面NEW一个线程类,然后重载run方法。这样就可以用这个线程类的实例start,就能够把一个线程run起来。run以后我们的JVM就会真正的去执行一个额外的线程,跟当前程序运行的主线程不一样。额外的运行一个线程,去执行我们的Thread 类里面重载的run方法逻辑。

在这个过程中,发生和执行了什么?

Thread 方法执行后,就会进入到我们的JVM层面。在JVM层面会把我们Java的Thread 对象转换成操作系统真实的线程对象。所有线程的真实资源其实都是操作系统层面的线程。这时候JVM实际上调用了操作系统的API去创建了一个操作系统的线程。然后再通过管理这个操作系统的整个生命周期,来实现我们的多线程的程序。在这个过程中,它需要现为我们的线程分配JVM的栈相关的内存,准备好以后就会启动操作系统的线程去执行我们在Thread对象里重载的run方法逻辑。整个执行完成以后,就会终结掉这个操作系统线程,方法退出。到这个时候Java线程的生命周期就结束了。

Java线程有三个层面。第一个就是Java层面主要是Thread对象的start和run方法。第二个就是JVM层面,它实际上一方面承接着我们Java层面的Thread对象,另一方面对应着我们操作系统底层真实的物理线程。整个Java线程的生命周期实际上是被我们JVM这个层面统一的管理。如果我们在Java代码里面没有使用Thread.start方法去new出一个新对象,直接调用Thread.run方法,实际上就么有JVM去真正创建一个操作系统线程。这时候的run方法跟我们本地类的方法一样都是在当前的主线程或者方法所在的线程直接运行方法里的代码。

线程和进程的区别是什么?

一个进程是我们操作系统启动一个程序的运行单元,一个进程里面可能会有一个或多个线程,这些线程可以共享这个进程它所在的进程空间里的各项资源比如说内存。而在不同的进程之间它们使用的内存一般是被隔离的(但是有办法可以共享)。

一个线程是操作系统调度实际运行任务执行方法的基本单元。

但是在现代操作系统中,进程和线程的概念越来越模糊。很多时候,我们可以把linux上面的进程看成稍微重量级一些的线程。

Java多线程

守护线程

对于一个JVM的进程来说,如果现在正在运行的所有线程都是守护线程(Deamon),那么JVM虚拟机就会把当前进程直接停止掉。
如果主线程已经执行完了,Deamon 线程里面的逻辑会来不及被执行。

Runnable

接口定义与实现
示例代码
Runnable是一个JDK接口,只有一个空的run方法,实际上Thread类本身就实现了Runnable接口,任何一个Thread类实例本身也是Runnable类型的。Thread的Run方法只是一个重载方法,在当前线程里面去调用run只是用当前线程去执行了这个任务。用Thread.start才是新起了一个线程来执行这个任务。

Thread#start():创建新线程
Thread#run() : 本线程调用

Runnable 不仅仅是JDK接口,也是线程生命周期里面的一个状态。

线程状态

简化版线程状态
当我们调用Thread.start()方法的时候,就会启动一个线程,这个线程的状态就是Runnable。因为我们CPU的时间片是分了很多片的,多个线程需要去竞争CPU的时间片。进入Runnable状态的线程如果没有抢到CPU时间片是没办法执行run方法的。一般Runnable和Running这两个状态在应用层次是没办法区分的,我们调用一个线程start()方法,就只能把线程提交到我们的操作系统去执行,进入一个Runnable的状态,操作系统CPU空闲有时间片它就执行。这个步骤叫操作系统底层的CPU调度层面,跟我们应用程序写的代码没有关系,代码控制不了。同样一个方法在运行过程中我们也可以通过一些方法调用让它接受一些指令编程一个不可运行的状态,也就是None-Runnable。同样也有可以让它恢复运行的一些方法调用和指令,重新达到Runnable的状态。整个任务在线程里被执行完以后这个线程本身也会达到一个终止状态。

Thread类

Thread类重要属性/方法

  • Runnable target
    表示新起的线程里面它具体要run的那段逻辑,那段逻辑对应的任务
  • start()
    启动一个新的线程
  • join()
    当前线程的代码里调用另外一个线程的join方法,表示停止当前线程,它要等待join的主体线程整个执行完终止掉以后它再继续往下执行。
  • currentThread()
    静态方法。获取到当前的线程对象。
  • sleep()
    静态方法。线程睡眠并让出CPU时间片。

wait & notify

wait & notify
这是一组改变线程运行状态的重要方法。

  • wait()
    让一个线程现在先暂停,等待其他线程唤醒它继续或者暂停执行一段时间。wait 是被 notify 方法唤醒的,所以可以不用传递时间参数。wait 会释放掉当前线程占有的锁。wait 被唤醒的时候会自动地再去尝试获取之前释放掉的锁。wait 设置了时间参数即使没有到时间也可以被 notify 唤醒。wait 是 object 对象的方法。
  • notify()
    发送信号通知1个wait线程
  • notifyAll()
    发送信号通知所有wait线程,把对应对象调用wait 方法导致暂停的线程全部唤醒。

wait 和 notify 方法是Java多线程在线程相互之间做统一的协调调度的一个重要的手段。

辨析:

  • Thread.sleep: 释放 CPU
  • Object#wait : 释放对象锁

Thread 的状态改变操作

  1. Thread.sleep(long millis),一定是当前线程调用此方法,当前线程进入 TIMED_WAITING 状态,但不释放对象锁,millis 后线程自动苏醒进入就绪状态。作用:给其它线程执行机会的最佳方式。
  2. Thread.yield(),一定是当前线程调用此方法,当前线程放弃获取的 CPU 时间片,但不释放锁资源,由运行状态变为就绪状态,让 OS 再次选择线程。作用:让相同优先级的线程轮流执行,但并不保证一定会轮流执行。实际中无法保证 yield() 达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。Thread.yield() 不会导致阻塞。该方法与 sleep() 类似,只是不能由用户指定暂停多长时间。
  3. t.join()/t.join(long millis),当前线程里调用其它线程 t 的 join 方法,当前线程进入 WAITING/TIMED_WAITING 状态,当前线程不会释放已经持有的对象锁,因为内部调用了 t.wait,所以会释放t这个对象上的同步锁。线程 t 执行完毕或者 millis 时间到,当前线程进入就绪状态。其中,wait 操作对应的 notify 是由 jvm 底层的线程执行结束前触发的。
  4. obj.wait(),当前线程调用对象的 wait() 方法,当前线程释放 obj 对象锁,进入等待队列。依靠 notify()/notifyAll() 唤醒或者 wait(long timeout) timeout 时间到自动唤醒。唤醒会将线程恢复到 wait 时的状态。
  5. obj.notify() 唤醒在此对象监视器上等待的单个线程,选择是任意性的。notifyAll() 唤醒在此对象监视器上等待的所有线程。

Thread 的中断与异常处理

  1. 线程内部自己处理异常,不要溢出到外层(Future 可以封装)。
  2. 如果线程被 Object.wait, Thread.join和Thread.sleep 三种方法之一阻塞,此时调用该线程的interrupt() 方法,那么该线程将抛出一个 InterruptedException 中断异常(该线程必须事先预备好处理此异常),从而提早地终结被阻塞状态。如果线程没有被阻塞,这时调用 interrupt() 将不起作用,直到执行到 wait/sleep/join 时,才马上会抛出InterruptedException。
  3. 如果是计算密集型的操作怎么办?
    采取设计来解决这个问题。
    分段处理,每个片段检查一下状态,是不是要终止。自变量。或者Sleep。

本质上 interrupt 方法和 InterruptedException 异常,一个在我们的内部一个从外部调用,它就组合成了一个给正在运行的线程内部传递一个信号,告诉它现在要不要额外的一些其它的操作,要不要终止掉。如果不用 interrupt 方法和 InterruptedException 异常,我们需要自己定义一个外部自变量来控制终止逻辑。

Thread 状态

Thread 状态图
一个Java里的线程或者叫操作系统的线程在整个运行的生命周期里它经历的所有的状态组成的一个状态流程图。

在流程图里,一个线程最开始被实例化,当我们在Java里new一个Thread对象,这时候实际上JVM和操作系统层面的线程还没有产生。

当我们调用了Thread.start()方法才真正的通过JVM创建了一个操作系统层面的线程对象。真正的进入了整个线程的生命周期。

这个时候实际的线程,它先处于一个就绪状态(就绪READY),然后它被系统调度的时候拿到CPU的时间片,它就可以真正的可以运行了。此时就是(运行中RUNNING)状态,真正的在执行run方法内部的那些代码。

一个线程正在运行的过程中,我们可以通过调用 Object.wait()、Object.join()、 LockSupport.park() 来让它们进入等待状态(等待WAITING)。也反过来可以用 Object.notify()、Object.notifyAll()、LockSupport.park(Thread) 来唤醒它们。又回到了就绪状态(就绪READY),重新获取CPU的时间片再继续被系统调度运行。

等待这块除了无参数的这种直接等待长时间等待以外,还可以加参数等待,这就是所谓的超时等待状态(超时等待TIMED_WAITING)。可以通过调用超时等待的方法 Thread.sleep(long)、Object.wait()、Thread.join(long)、LockSupport.parkNanos()、LockSupport.parkUntil() 进入超时等待状态(超时等待TIMED_WAITING)。超时等待除了可以被Object.notify()、Object.notifyAll()、LockSupport.park(Thread) 方法自动唤醒以外,它们还可以达到了超时这个时间被自动唤醒。

以上这些都是可以主动地对线程进行的操作。在Java里除了主动地对线程进行操作,让它进入一个暂停等待这样的WAITING状态。当线程遇到了同步代码块、临界区、锁,它有可能被动的进入了所谓的等待状态,这种状态叫做阻塞状态(阻塞BLOCKED)。

当遇到阻塞,同步块的阻塞,怎么样才能够继续进行。必须要当前线程,它能够拿到竞争的这把锁。它拿到了一个锁,它就可以继续执行下去。被阻塞(BLOCKED)的线程拿不到锁就一直被暂停在那里。

最后,线程执行完成之后,就进入到了终止状态(终止TERMINATED)。

主动的等待叫WAITING,被动的等待叫BLOCKED。

所以可以把线程的状态里面最核心的几个部分,概括为所谓的 RWB。R就是 Runnable running状态,整个可以运行正在运行的状态。W就是waiting,waiting有直接的不带时间的长时间的waiting,一直等到别人唤醒的。或者是超时的waiting,调用wait、sleep方法传递了时间参数,到了一定时间自动地唤醒。这两种状态都是W。最后一种重要的状态就是Block,被动的遇到了一个同步块、增强锁拿到锁就进入等待,一直等待到自己拿到锁了就可以继续执行。

任何一个线程它本身也作为一个对象,在这个线程被执行完最终退出之前会清理线程所依赖的各种上下文关系。其中就包括了以它作为一个对象,调用的所有的.wait()方法操作都会被它唤醒。JVM层面会调用对象的t.notifyAll()通知所有以它作为一个对象加了wait加了锁的这样的一些线程,统统都继续执行被唤醒了。如果wait方法没有加超时参数,那么它一定需要有对应的notify或者notifyAll方法来唤醒它,如果我们在Java层面找不到notify这样的代码,那么在JVM实现的层面一定会有相应的这种操作的。

线程安全

多线程执行会遇到什么问题?

线程安全问题
多个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。

导致竞态条件发生的代码区称作临界区。

不进行恰当的控制,会导致线程安全问题。

并发相关的性质

原子性

原子性:原子操作,注意跟事务 ACID 里原子性的区别与联系

对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
原子性
只有语句1是原子操作。这个操作本身不能够再拆解成多步不同的操作。

可见性

可见性:对于可见性,Java 提供了 volatile 关键字来保证可见性。

当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

另外,通过 synchronized 和 Lock 也能够保证可见性,synchronized 和 Lock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。

volatile 并不能保证原子性。

在JVM内部,当两个线程同时想去操作一个变量。这时候这两个线程分别持有变量的副本。那么默认的即时修改都是先发生在当前这个线程所持有的副本上,然后再被同步到我们的主内存。

在有些条件下为了使JVM里的线程每次读写操作的都是主内存里的变量的值。Java引入了 volatile 关键字,当一个变量定义的时候使用 volatile 修饰,那么 JVM 就会保证对它的读和写都是立即被更新到我们的主内存的。这个时候如果有其他线程来访问变量,它们一定会从主内存拿到最新的值。

有序性

有序性:Java 允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。可以通过 volatile 关键字来保证一定的“有序性”(synchronized 和 Lock也可以)。

另外一方面我们总结出来一套原则,我们根据这8条相关的代码或者执行的一些行为就可以在我们整个多线程的代码运行过程中找到一些锚点。这些锚点相互之间是可以判断它们的先后执行顺序的(虽然它们跨了线程)。

happens-before 原则(先行发生原则):

  1. 程序次序规则:一个线程内,按照代码先后顺序执行
  2. 锁定规则:一个 unLock 操作先行发生于后面对同一个锁的 lock 操作
  3. Volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  4. 传递规则:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出 A 先于 C
  5. 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每个一个动作
  6. 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行
  8. 对象终结规则:一个对象的初始化完成先行发生于他的 finalize() 方法的开始

synchronized 的实现

  1. 使用对象头标记字(Object monitor)
  2. Synchronized 方法优化
  3. 偏向锁: BiaseLock
    对象头

Synchronized 在Java中一般被称为同步块关键字,这个关键字可以加在对象上,也可以加在一个方法上,也可以加在一块代码上。不管加在什么地方,它其实都是针对某个对象加锁。

这个锁是加在它对应的锁的想要锁的对象头里面的具体的一些标志位上。比如说上图就是一个对象头的结构,标记字按照二进制位也可以划分成很多的位。这里面就有锁的标志位。

当我们对于这个代码一个方法或者一个对象上面写了Synchronized,其实我们就锁定了当前比如说这个this对象。或者锁定了当前我们指定的这样的一个具体的对象。在它上面加了对应的锁。这个锁有可能是偏向锁也可能是轻量级锁。还可能是重量级的锁。可能是多种不同的锁。但是锁的为止就是标记的对象上。当下一个线程也进入临界区这个代码块了。我们标记有synchronized,不管是一个方法,那个方法锁定的其实就是整个方法区。如果我们标记的是一段代码,那么锁定的是这段代码进入这个代码。如果我们标记的是一个对象,那就进入这要执行这段代码上先锁一个对象。总之要么是对this对象,要么是对我们指定的具体对象。在它上面对象头里的标志位里加了锁标志。
锁状态

加在方法上,那么就是当一个对象调用这个方法的时候,就把这个对象的this对象本身把它的头上加了锁的标志位改成对应的锁。

如果在代码块上加锁,那么相当于让线程执行到这个代码块的时候,在this对象上加同步块。

加在方法上加在代码块上,使用的单独的锁对象有什么区别?

区别就是它们使用的锁的粒度不同,我们使用锁代码块代替锁整个方法这种方式其实就可以让我们同步的代码大家并发的时候被卡住,执行的代码其实粒度变小了。假如一个方法有10行代码,我们分析一下只需要对其中的3行代码加同步块,那么其他的7行代码多线程之间是可以并发执行的。就只有这3行代码被锁住了要做同步执行,整个程序并发的程度就更高了,效率也就更高了。

同样的我们有时候也可以做锁分离,也就是说我们分析一下它们的业务逻辑各种不同的方案里我们用同步块锁定的时候,我们可以锁不同的一些锁对象,避免同时都锁在this对象上,this对象相当于只有一把锁来回锁来回抢。 我们可以用多个new出来的Object对象来做多把锁,那么不同的方法不同的代码片段就可以锁定不同对象。那么在同一时间多个线程可以拿到多把锁。所以整个程序的并发粒度也进一步地提升了。所以这块优化的方式和大家选择的方式总体来说就是:减少锁的粒度,增大并发的粒度。一方面是在代码范围控制,另一方面是用锁分离。

偏向锁

偏向锁是当一个线程它占有了某个锁,在某个对象头部做了一些修改,内存创建了锁结构。这时候如果继续有多个线程来增强这把锁,我们默认地假如说还让刚才占有了这把锁的线程现在获取到锁继续使用这个锁的资源进行操作成本是最低的,因为对象上标记的锁对象指向了相关的数据结构,都是指向这个线程的就不需要修改了。那么这个锁还是分配给他,所以这就是所谓的偏向锁。

比如说一个人长期干某种活干熟了,下次再要找人来干这个活,还找他干干得最快最顺利。转移的转交的手续也不用办了。

轻量级锁

轻量级锁其实是对我们的 synchronized 同步机制做一个优化,默认去获取锁的时候先不完全地悲观地锁定它,而是通过一种类似于CAS的方式,尝试着去做一下操作。如果能做成了其实就不用上很重的锁。如果做不成发现已经被其他的线程占用了,这时候再转成重量级的锁进入排队队列,等待拿到这个锁再继续操作。

这一个就是所谓的乐观锁的机制。所以轻量级的锁是一个CAS的乐观锁的机制。

重量级锁

重量级锁是最原本的 synchronized 在早期的JDK版本里的实现机制。就是直接使用一个重量级的锁的数据结构,把对象上面标记成自己占用的这样一些标记。

重量级的锁开销是比较大的,轻量级的锁开销相对比较小。偏向锁其实也可以看作是一种乐观锁的机制。

GC标记

GC标记也是一种锁。

volatile

  1. 每次读取都强制从主内存刷数据
  2. 适用场景: 单个线程写;多个线程读(写数据刷的频率不是特别多)
  3. 原则: 能不用就不用,不确定的时候也不用
  4. 替代方案: Atomic 原子操作类

加了volatile以后,我们很多的操作都不能直接使用本地的变量副本,都必须去主内存里面刷。所以能不能就不用,尽量少用这个关键字。
volatile图
volatile 可以保证部分有序性,起到栅栏作用,保证指令不会被重排。
那么,语句1和2,不会被重排到3的后面,4和5也不会到前面。
同时可以保证1和2的结果是对3、4、5可见。

final

final图

final可以修饰在类、方法、局部变量、类实例属性、静态属性上。

final 关键字就意味着,被修饰的对象被定义完了以后不允许被修改。如果是类不允许被继承,如果是方法不允许被重载。如果是局部变量,那么它在定义的时候new出来也不允许再次赋值。如果定义在一个类的某个属性上,那么这个属性只能在构造函数里赋值,赋值完了以后也不能再动了。

也就是说被final定义完了以后的东西基本上就只是可读的。这种东西跨线程的时候就是安全的。所以我们在写代码的时候在很多地方明确地写上final是非常有利的。如果这个变量你写了final以后,后面再对它进行修改就会提示错误。我们就知道变量本身在后面执行的过程中,它的值就不会再变了。那么读出来的一般情况下产生这种线程安全的问题的可能性就会变小。

另外用final static 声明的原生的数值类型比如小的int、float,它们其实就是一个所谓的常量。Java里没有给常量单独地起一个关键字。像别的语言里面可能有const能声明一个常量。

常量和变量有什么区别?

常量和变量的区别就是在编译期,常量引用的是一个变量名称但是实际上会被替换成具体的数值。也就是说这个类里引入了另外一个类里的常量,编译完以后会把常量变量名抹掉替换成具体的数值。在跨包引用常量的时候这个类与引用类就可以没有任何关系了。如果引用的是变量,那就不一样了。它还需要真实在运行中去另外一个包里的类里面去拿变量的值。

线程池原理与使用

线程池

因为线程这个资源对于系统来说是一个占用的资源比较多比较重量级的一个资源,这类的资源我们一般更倾向于直接维护好一个池化的一个资源池,每次需要使用的时候从池里拿到再使用。因为我们CPU的核心数是有限的,所以线程的物理的资源也是有限的,线程太多的话会导致线程相互之间上下文切换开销比较重,实际的并行执行效率并不高。同时,如果一瞬间进来的流量很大,线程占用满了以后也需要有相对应的策略去处理这些流量请求。

所以说我们要实现一个通用的多线程的程序,一组通用的专注于业务处理这样的一个基础设施。JDK已经帮我们实现了线程池的整个抽象的设计和具体的实现,对应的各种我们需要去调配的这些参数。我们可以直接拿JDK实现的线程池相关的API来使用。
线程池A

  1. Excutor: 执行者,顶层接口
  2. ExcutorService: 接口 API
  3. ThreadFactory: 线程工厂
  4. ThreadPoolExecutor
  5. Excutors: 工具类,创建线程

Executor – 执行者

Excutor 接口
线程池类继承结构

线程池从功能上看,就是一个任务执行器

submit 方法 -> 有返回值,用 Future 封装
execute 方法 -> 无返回值

submit 方法还异常可以在主线程中 get 捕获到
execute 方法执行任务是捕捉不到异常的

ExecutorService

ExecutorService重要方法
shutdown():停止接收新任务,原来的任务继续执行
shutdownNow():停止接收新任务,原来的任务停止执行
boolean awaitTermination(timeOut, unit):阻塞当前线程,返回是否线程都执行完

优雅停机

当我们希望我们的业务系统在做重启或者重新发布部署老的机器我们要下线新的机器再发上去把流量导进来,这个过程中我们希望做到优雅停机。正在执行了一半的业务不被强制性地打断,导致我们数据库和我们业务的一些状态和数据出现不一致的极端情况。优雅停机对我们的用户特别友好,所以一般情况下我们就可以使用 shutdown() 方法。

如果优雅停机正在执行的业务方法时间不可控,5分钟10分钟都还没有执行完。所以我们一般可以写这么一个逻辑,先 shutdown,shutdown 以后可以调用 ExecutorService 的另外一个方法 awaitTermination(timeOut, unit) 阻塞当前的主线程比如说三分钟或者一分钟。到时间后取消阻塞同时返回是否线程都已经执行完毕。如果还没有执行完我们可以强制地再去调用一次 shutdownNow() 。根据我们业务逻辑的设计,如果一个线程超过一分钟或者三分钟还没有执行完,说明在我们业务上来看它已经超时了。这时候我们就先可以强制把它关闭了。

ThreadPoolExecutor

ThreadPoolExecutor execute方法
ThreadPoolExecutor 提交任务逻辑:

  1. 判断 corePoolSize 【创建】
  2. 加入 workQueue
  3. 判断 maximumPoolSize 【创建】
  4. 执行拒绝策略处理器

当任务被添加到线程池的时候,首先第一步先判断当前正在运行的线程的数量是否达到了线程池的核心线程数。如果没有达到那就直接创建一个新的线程来运行这个任务。

然后不停地往线程池添加任务。这时候当新加一个任务进来的时候一判断发现当前的线程数已经达到了核心线程数。这个时候就会把这个任务直接添加到工作队列(缓冲队列)。核心线程数都在忙来不及处理的话接下来的任务就让它在缓冲队列工作队列里面排队。

缓冲队列也是有大小的,不能无限制的在堆,一定会把内存堆爆的。所以这时候接下来不断地再加任务,把缓存队列也填满了。再来新的线程就会判断当前是否达到了我们的最大线程数。如果没有达到最大线程数也会再创建新的线程来处理新添加的任务。

如果最大线程数也达到了上限,缓冲队列也满了,这时候再有线程进来就会执行拒绝策略。

线程池最小、最大核心数、缓冲队列的设计逻辑

目前逻辑设计的原因是因为物理线程数的资源其实特别有限。如果直接创建了大量的线程资源,其实是对资源的非常大的浪费。所以使用线程的时候是相对比较克制的,尽量使用比较少的这种线程数,期望它能够把当前的任务都处理掉。实在处理不过来的时候就往缓冲队列里面堆,如果缓冲队列也满了还是处理不过来的时候,这时候其实就可以相对地大概能得到一个判断,有可能是前面正在处理的核心线程数的线程中间经过了特别多的I/O等待之类的这样一些暂停。才导致了一直没有把前面的任务给处理得过来,所以就可以假设知道这些线程正在经历一些I/O等待,那么其实这些线程已经把CPU的资源都释放掉了,在很多的时间里是不占用CPU资源的。这时候再起一些线程,这些线程有可能就可以去利用前面的线程放弃掉的没有利用的那一片的CPU的资源CPU的时间片。从而就可以使得整个的运算效率线程池执行的效率有所提升。如果前面这几个核心线程他们中间没有I/O等待,新添加线程过多线程也没有用。

线程池参数

缓冲队列

BlockingQueue 是双缓冲队列。BlockingQueue 允许两个线程同时向队列一个存储,一个取出操作。在保证并发安全的同时,提高了队列的存取效率。

  1. ArrayBlockingQueue:规定大小的BlockingQueue,其构造必须指定大小。其所含的对象是 FIFO 顺序排序的。
  2. LinkedBlockingQueue:大小不固定的BlockingQueue,若其构造时指定大小,生成的
    BlockingQueue 有大小限制,不指定大小,其大小有 Integer.MAX_VALUE 来决定。其所含的对象是 FIFO 顺序排序的。
  3. PriorityBlockingQueue:类似于LinkedBlockingQueue,但是其所含对象的排序不是 FIFO,而是依据对象的自然顺序或者构造函数的 Comparator 决定。(优先级)
  4. SynchronizedQueue:特殊的 BlockingQueue,对其的操作必须是放和取交替完成。(只能存1个数据)
拒绝策略
  1. ThreadPoolExecutor.AbortPolicy: 丢弃任务并抛出 RejectedExecutionException异常(默认策略)
  2. ThreadPoolExecutor.DiscardPolicy:丢弃任务,但是不抛出异常
  3. ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务
  4. ThreadPoolExecutor.CallerRunsPolicy:由调用线程(提交任务的线程)处理该任务(这个策略用的比较多)
ThreadFactory

线程工厂示例图
线程工厂接口。线程池里面的线程都是由线程工厂创建的,可以批量地用工厂创建一堆具有一些相同配置特定属性的这样一组线程放入线程池里。

ThreadPoolExecutor

线程池重要属性/方法创建线程池示例如果都是计算密集型的任务,线程运行的时候都没有等待,将最小核心线程数设置为CPU的个数,它就可以充分地利用现在的CPU。假如说闲杂执行的任务中有一部分的等待,此时可以在任务线程等待的时候再创建多一倍的线程来执行更多的任务,使用这种方式来充分地利用CPU的时间片。

创建线程池方法

Executors 工具类提供了简化创建线程池的方法。最典型的就是以下4种常见的线程池类型。大部分场景下使用这4种就够了。

  1. newSingleThreadExecutor
    创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
    2.newFixedThreadPool
    创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
  2. newCachedThreadPool
    创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。
    此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。
    4.newScheduledThreadPool
    创建一个大小无限的线程池,此线程池支持定时以及周期性执行任务的需求。

创建固定线程池的经验

平时工作中使用最多的是固定线程池。如果线程数设置的太小了不能充分的利用CPU的资源,设置的太大了线程间上下文的切换CPU相互之间的竞争导致开销太大,也是浪费了很多系统资源效率也不是那么高。

假设CPU核心数为N,那么需要先来判断一下我们这个系统到底是一个CPU密集型的就是计算密集型的,还是一个I/O密集型的比如网络I/O特别多线程在执行过程中等待特别多的系统。

  1. 如果是 CPU 密集型应用,则线程池大小设置为 核心线程数 N 或 N+1
  2. 如果是 IO 密集型应用,则线程池大小设置为 2N 或 2N+2

这是一个经验规律。也就是说在线程执行的任务里I/O本身占据的比例越大,我们设置线程池里的这个线程数的大小应该越大。如果I/O执行的这种占用的时间片I/O的比例非常大,我们甚至可以进一步地去提升线程池的线程数量。因为前面执行任务的这些线程中间在I/O的过程中大部分的时间在等待,其实没有完全在使用我们CPU的资源。这些被浪费的CPU资源其实就可以多创建一些线程来充分地利用它们。

Callable – 线程池基础接口

Callable 接口重要方法
Callable 接口使用示例
与Runnable接口对比:

  • Runnable#run()没有返回值
  • Callable#call()方法有返回值

如果我们期望多线程执行任务有返回值,我们可以实现 Callable 接口,使用线程池的 submit(Callable task) 方法去执行。

submit(Callable task) 的返回值是一个 Future 接口。

Future – 线程池基础接口

Future 接口对应着异步执行的一个任务最终需要拿到它的返回值,它最重要的方法就是 get。如果业务对处理时间有要求,需要使用带超时参数的 get 方法,超时则会抛出异常。
Future 重要方法说明
Future 使用示例

Java 并发包 (JUC)

JDK 类库

JDK 核心库的包

jdk核心库包
runtime 依赖的最核心的 jar 包名称叫 rt.jar,大致分为两大类,第一类是java开头的,第二类是javax或者sun开头的。java开头的都是jdk公开的api,所有的jdk不管是什么版本什么厂商都要实现它。

java.util.concurrency

java.util.concurrency

  • 锁机制类 Locks : Lock, Condition, ReentrantLock, ReadWriteLock,LockSupport
  • 原子操作类 Atomic : AtomicInteger, AtomicLong, LongAdder
  • 线程池相关类 Executor : Future, Callable, Executor, ExecutorService
  • 信号量三组工具类 Tools : CountDownLatch, CyclicBarrier, Semaphore
  • 并发集合类 Collections : CopyOnWriteArrayList, ConcurrentMap

锁的原理

我们可以把锁看作是对某一块资源占用的一个标记所有权,谁拿到锁就相当于谁占有这个资源现在使用权,它就可以继续操作。

为什么需要显式的 Lock

synchronized 可以加锁,
wait/notify 可以看做加锁和解锁。
那为什么还需要一个显式的锁呢?

synchronized 加锁的缺点
  1. 同步块的阻塞无法中断(不能 Interruptibly 打断中断)
  2. 同步块的阻塞无法控制超时(无法自动解锁),会一直在排队被挂死
  3. 同步块无法异步处理锁(即不能立即知道是否可以拿到锁)
  4. 同步块无法根据条件灵活的加锁解锁(即只能跟同步块范围一致)

更自由的锁: Lock

lock包结构

  1. 使用方式灵活可控
  2. 性能开销小
  3. 锁工具包: java.util.concurrent.locks

lock接口设计
lock 是一个显式的锁接口,它相当于把我们jvm内存支持的锁机制在java这一层用更轻量级的锁来设计和重新实现。所以它使用起来更加的灵活可控同时开销更小。lock的接口设计就是针对 synchronized 已知的几个缺点来改进进而设计出来的一种锁。如果在代码比较简单的情况下lock并不一定比synchronized性能高,脱离场景来谈性能都是耍流氓。但是我们可以说一般场景下如果我们设计的比较合理,线程相互之间的这种机制比较复杂,这时候我们用显式的lock的锁可能性能会更好。

基础接口 - Lock

lock接口重要方法
一个锁可以new出来很多个不同的 condition,每个condition我们都可以把它看作是一把钥匙一个信号一个红绿灯信号,这是我们可以靠灵活地使用这些红绿灯和信号来控制这把锁可以产生很多种不同的行为。当我们使用了多个new condition,在一把锁上面创建了多个条件,就相当于我们把一把大锁直接给分解出来了多个子一级的子锁。我们在具体的业务使用场景里就可以通过这些锁的不同的使用条件来灵活地使用这多把小锁。
lock示例代码lock示例代码测试代码

什么是可重入锁?

– 第二次进入时是否阻塞。已经拿到锁的线程再次去获取锁的时候不会被阻塞,我们经常使用的锁都是可重入锁。如果一个线程使用了不可重入锁,再次去拿锁的时候被阻塞也没办法执行释放的动作,这个线程就会被卡死形成死锁。

什么是公平锁?

– 公平锁意味着排队靠前的优先。
– 非公平锁则是都是同样机会。

ReadWriteLock(读写锁) – 接口与实现

读写锁接口重要方法
读写锁实现类示例方法
注意:ReadWriteLock 管理一组锁,一个读锁,一个写锁。

在写的时候,如果我们要保障读是一致的。几个线程并发来读读到的数据都一致。那么我们需要在写的时间点上加个锁来控制读。

读锁可以在没有写锁的时候被多个线程同时持有,写锁是独占的。
所有读写锁的实现必须确保写操作对读操作的内存影响。每次只能有一个写线程,但是同时可以有多个线程并发地读数据。ReadWriteLock 适用于读多写少的并发情况。

基础接口 - Condition

Condition 锁条件接口重要方法
通过 Lock.newCondition() 创建。
可以看做是 Lock 对象上的信号。类似于 wait/notify。

LockSupport–锁当前线程

LockSupport重要方法/属性
LockSupport 类似于 Thread 类的静态方法,专门处理(执行这个代码的)本线程的。
思考:为什么 unpark 需要加一个线程作为参数?
因为一个 park 的线程,无法自己唤醒自己,所以需要其他线程来唤醒。

用锁的最佳实践

Doug Lea《Java 并发编程:设计原则与模式》一书中,
推荐的三个用锁的最佳实践,它们分别是:

  1. 永远只在更新对象的成员变量时加锁
  2. 永远只在访问可变的成员变量时加锁
  3. 永远不在调用其他对象的方法时加锁。也就是说我们加锁的时候要加在自己方法的代码里面,如果我们调用另外一个方法,方法内部我们不清楚,所以最好的做法是让对象它的方法内部根据自己的业务逻辑的判断需要看要不要对某些代码进行加锁,我们在外部加锁的粒度太大没办法细化和控制它。

KK 总结为一个原则两个小点。
最小使用锁原则:

  1. 降低锁范围:锁定代码的范围/作用域。避免将锁直接加在方法上,而是找出一百行代码中真正需要加锁的那二十行代码。提高并发时的运行效率。
  2. 细分锁粒度:将一个大锁,拆分成多个小锁。比如把一个锁变成读写锁,变成多个锁在不同的条件下去使用它们。

并发原子类

Atomic 工具类

原子类工具包:java.util.concurrent.atomic
原子类包目录结构
原子类使用示例

无锁技术 – Atomic 工具类

原子类在多线程并发的场景下不是通过加锁来实现的,它采用的是一种无锁技术。
无锁技术的底层实现原理

  • Unsafe API - CompareAndSwap
  • CPU 硬件指令支持 - CAS 指令
  • Value 的可见性 - volatile 关键字

原子类 volatile 关键字
原子类Unsafe api 方法
Unsafe API 里面的 CompareAndSwap 方法是 native 方法,实现代码在jvm内部,jvm,jvm 实际上最终调用的是 cpu 硬件指令支持的一个方式叫 cas 指令。cas 指令它相当于一个乐观锁。cas失败的时候会重新再去取值操作后继续做比较,直到对比成功。cas 这个机制需要cpu本身支持。

核心实现原理:

  1. volatile 保证读写操作都可见(注意不保证原子);
  2. 使用 CAS 指令,作为乐观锁实现,通过自旋重试保证写入。

锁与无锁之争

到底是有锁好,还是无锁好?

CAS 本质上没有使用锁。

并发压力跟锁性能的关系:

  1. 压力非常小,性能本身要求就不高;(cas 和 加锁都一样)
  2. 压力一般的情况下,无锁更快,大部分都一次写入; (减少了加锁解锁巨大的成本开销,建议使用cas)
  3. 压力非常大时,自旋导致重试过多,资源消耗很大。

乐观锁绝大多数情况下都对我们比较有利。乐观锁,悲观锁在数据库方面也有用到。

LongAdder 对 AtomicLong 的改进

LongAdder(Long类型的增加器) 的改进思路:

  1. AtomicInteger 和 AtomicLong 里的 value 是所有
    线程竞争读写的热点数据;
  2. 将单个 value 拆分成跟线程一样多的数组 Cell[];
  3. 每个线程写自己的 Cell[i]++,最后对数组求和。

通过分段思想改进原子类,多路归并的思想:

  • 快排
  • G1 GC
  • ConcurrentHashMap

爬山,做一个大项目,都需要加里程碑,也是分段。

并发工具类

什么是并发工具类

多个线程之间怎么相互协作?
前面讲到的:
1、wait/notify
2、Lock/Condition
可以作为简单的协作机制。
但是更复杂的,需要这些线程满足某些条件(数量,时间)。

更复杂的应用场景,比如

  • 我们需要控制实际并发访问资源的并发数量
  • 我们需要多个线程在某个时间同时开始运行
  • 我们需要指定数量线程到达某个状态再继续处理

为了应对这些场景,我们需要用到并发工具类。

AQS(抽象队列式的同步器)

在java并发包里,为了实现更灵活更细粒度的更好地控制我们并发的这些线程以及它们相互之间协作和配合的方式,所以在这些包里抽象出来一个概念叫 aqs。也就是所谓的 AbstractQueuedSynchronizer。

AbstractQueuedSynchronizer,即队列同步器。它是构建锁或者其他同步组件的基础(如 Semaphore、CountDownLatch、ReentrantLock、ReentrantReadWriteLock),是 JUC 并发包中的核心基础组件,抽象了竞争的资源和线程队列。
AQS 原理示意图

  • AbstractQueuedSynchronizer:抽象队列式的同步器
  • 两种资源共享方式: 独占 | 共享,子类负责实现公平 OR 非公平

可重入锁,读写分离锁,都是基于aqs来实现的,所以aqs是java并发包里面最核心的基础组件(有可能没有之一)。因为它抽象了我们多个线程组成的线程队列,以及这些线程竞争的资源。我们通过控制线程队列的行为以及大家共享竞争的资源,我们就可以实现非常灵活的对多线程的行为进行细粒度的控制。

state就是多个线程竞争的资源,它是一个integer。我们也可以把它看作是一个状态叫 state。因为多个线程都要竞争它,它是需要加volatile的,让它对多个线程都是可见的。这个时候当有线程过来访问它或者使用它需要占有这个资源。那么第一个线程它是最开始来的,它是能够拿到我们资源的,获取资源的使用权。这个时候它的使用方式可以是独占的也可以是共享的。也就是说第一个线程拿到了state就相当于拿到了线程对应的锁。如果再接下来并发的有多个线程都想来获取资源,那么就需要排队了。这些排队的线程直接就会组成一个CLH的双向队列(FIFO先进先出)。这个时候我们通过管理这个线程的队列。我们就可以很好地来控制多个线程相互之间它们协作的这种行为。同样基于这样的设计我们也可以实现公平锁和非公平锁。如果是公平锁那么当线程1处理完了以后把这个锁释放掉了,那么线程2现在就变成了头。它等待时间最久,它就接下来获取了当前的资源获取了锁可以并发的执行了。如果在整个排队是一个非公平的形式,那么就是说在队列里面随机找出来一个就可以拿到当前的资源拿到当前的锁,所以它实现起来也比较方便。

Semaphore - 信号量

Semaphore 举例
Semaphore 示例代码

  1. 准入数量 N, N =1 则等价于独占锁
  2. 相当于 synchronized 的进化版

使用场景:同一时间控制并发线程数

简单来说我们可以把它类比成在十字路口控制交通的这样一种信号,比如说我们允许多少车辆并发地通过这个路口。比如说我们现在可以控制一个方法可以允许三个线程并发执行,那么当其中一个线程执行完成后有一个线程会立马能进来。也就是说在同一时刻就只有三个并发线程是可以同时进行的。

假如说我们现在自己写了一个业务的实现方法,里面能够提供某种业务的处理能力。这时候我们是被别人第三方其他的同事调用的,到底他们用多少线程调用我们,它们用多大的线程池,new 了多少个线程我们是不知道的。假如说我们在我们的业务代码里面我们定义了一个信号量。给定了一个信号比如说是4,那么其实每时每刻我们自己写的这块业务代码就可以被我们的信号量比较好地去保护起来。在多线程的环境下它们起10个还是100个线程调我们。实际上我们这块业务代码每时每刻就只允许4个线程并发的执行,其它的线程都要排队。

CountdownLatch

CountdownLatch 原理CountdownLatch 重要方法
CountdownLatch 代码示例
阻塞主线程,N 个子线程满足条件时主线程继续。
场景: Master 线程等待 Worker 线程把任务执行完
示例: 等所有人干完手上的活,一起去吃饭。

CyclicBarrier

CyclicBarrier 重要方法
CyclicBarrier 原理
CyclicBarrier 代码示例
场景: 任务执行到一定阶段, 等待其他任务对齐,阻塞 N 个线程时所有线程被唤醒继续。
示例: 等待所有人都到达,再一起开吃。

CyclicBarrier 不是基于 aqs 来实现的。

CountDownLatch 与 CyclicBarrier 比较

CountDownLatch 和 CyclicBarrier 这两个类非常相似,都是给定一个允许的信号的数量,然后控制多个线程大家达到某种状态,然后我们再进一步地继续做某种操作。
CountDownLatch 与 CyclicBarrier 比较图解
CountDownLatch 与 CyclicBarrier 比较图解2

Future/FutureTask/CompletableFuture

普通模式和Future模式的区别
对于我们最简单的线程内的同步调用,线程现在调用这个方法方法返回结果,线程就会等待这个结果返回回来,拿到结果再继续进行处理,这种模式有等待时间很多时候我们并不知道等待时间是多少。

当我们使用多线程的时候,异步地去做方法调用的时候,我们就可以起一个新的线程去调用我们某一个方法。调用方法的时候我们可以拿到一个返回值。这个时候我们可以通过我们的线程池或者callable这样的一些机制确保这个额外的线程异步的线程它们执行的方法返回的结果封装在一个Future。然后我们再通过Future.get或者加一个超时时间把这样一个异步线程执行的结果再拿到。那么当前的线程在调用了异步方法另外一个线程处理和最终调用Future.get中间这段时间它就可以去干别的事情,这是它的优势。同时对于一个异步执行的任务,一个类似于之前Runnable这样的一个方法,我们也可以通过FutureTask把它封装成一个能够带返回值的任务。

概括来说,Future.get最终还是把我们的异步调用转换成了一个同步的这样的获取结果的一个过程。但是在很多场景下,我们这种朴素的Future.get异步转同步的这种等待的方式可能也不是一个特别好的做法。它的应用性有待进一步的提高。比如说有时候我们希望不直接这样用Future.get在当前线程里等待。我们希望通过回调的方式拿到异步线程执行的结果。有时候我们获取到了一个异步执行的结果以后,我们希望进一步的对它的结果进行一种变换或者转换。甚至很多时候我们有多个异步多个线程的操作,我们希望把它们的结果进行一些组合和封装。同样的当我们有多个线程并发执行的时候,可能线程执行的过程中就抛出了异常。用Future.get的时候,Future.get其实把异常能够hold住,然后再进一步的抛出来。但是假如说我们不用这种方式,那么我们也希望有一种比较友好的方式去处理。比如说异常的回调,抛出异常了我们该怎么处理它。这个时候并发工具包里进一步的提出来了一个CompletableFuture这样的一个封装的使用起来特别方便的类。特别是我们基于java8的lambda表达式能够实现一些特别灵活的特别复杂的一些这种异步的获取结果。对结果进行转换中间我们采用回调的方式进行组合。我们采取异常处理回调函数的这种方式。这样的话我们就可以通过CompletableFuture来非常灵活非常方便非常友好地访问我们这些异步的多线程他们执行的一些业务逻辑。把它们的结果拿回来按照我们想要的一些方式去处理它。
Future的分类
CompletableFuture 重要方法

常用线程安全类型

JDK 基础数据类型与集合类
常用数据结构参考链接

ArrayList

基本特点:基于数组,便于按 index 访问,超过数组需要扩容,扩容成本较高

用途:大部分情况下操作一组数据都可以用 ArrayList

原理:使用数组模拟列表,默认大小10,扩容 x1.5,newCapacity = oldCapacity + (oldCapacity >> 1) ,计算使用移位运算符。

安全问题:

  1. 写冲突:
    – 两个写,相互操作冲突
  2. 读写冲突:
    – 读,特别是 iterator 的时候,数据个数变了,拿到了非预期数据或者报错
    – 产生 ConcurrentModificationException

ArrayList 每次扩容都会出现触发底层数组转移复制的操作,所以它扩容的成本相对是比较高的。

transient 关键字

在这里插入图片描述
transient 关键字是在用Java默认的序列化的时候告诉我们的JVM这个类的这个字段不被序列化到最终结果里去。

移位运算符
<<  : 左移运算符,num << 1,相当于num乘以2

>>  : 右移运算符,num >> 1,相当于num除以2

>>> : 无符号右移,忽略符号位,空位都以0补齐

LinkedList

基本特点:使用链表实现,无需扩容

用途:不知道容量,插入变动多的情况

原理:使用双向指针将所有节点连起来

安全问题:

  1. 写冲突:
    – 两个写,相互操作冲突
  2. 读写冲突:
    – 读,特别是 iterator 的时候,数据个数变了,拿到了非预期数据或者报错
    – 产生 ConcurrentModificationException

LinkedList 源码方法
LinkedList 不需要扩容,ArrayList 是需要指定大小的(底层数组长度默认为10),而链表它是不需要指定大小的,因为它的底层是一个Node元素,只要有元素往后面不停的加新节点串起来就行了。所以它容量一般来说除了我们内存的限制是没有大小的限制的,对它的修改也比较容易。ArrayList当我们往中间或者头部插入元素的时候需要把后面所有的元素都往后挨个挪一个位置。LinkList 因为都是每个指向下一个的节点,我们在中间插入一个元素的时候,只需要把原先的这两个元素中间插入一个元素,更新一下前后两个元素的 next 和 prev 指针即可。在这个过程中没有动任何其他元素的位置,不需要那么频繁的挪动。所以LinkedList在我们不知道整个数组整个List的容量的时候比较有用。

List线程安全的简单办法

既然线程安全是写冲突和读写冲突导致的
最简单办法就是,读写都加锁。
例如:

  • 1.ArrayList 的方法都加上 synchronized -> Vector类
  • 2.Collections.synchronizedList,强制将 List 的操作加上同步
  • 3.Arrays.asList,不允许添加删除,但是可以 set 替换元素
  • 4.Collections.unmodifiableList,不允许修改内容,包括添加删除和 set

List 线程安全的简单办法
如果现在的代码已经写好了,如何保证最小程度的修改能够让我们的代码做到线程安全,可以使用Collections里面的方法来给List的操作加锁。它的原理是给我们的List加类一层包装方法,有点像装饰器设计模式。

CopyOnWriteArrayList

加了同步块的代码,对所有的 set get 方法上加了 synchronized 关键字的这样一个集合类它到底现在是不是线程安全的。答案是否定的,它不是线程安全的。我们对一个List的set add remove 和get在所有的操作方法上都加了同步块,加了 synchronized 关键字。那么当多线程去访问这单个的方法的时候,大家会被卡住变成线程安全的。但是现在这几个方法本身会有多个线程同时来访问。那么调用这不同的方法的线程相互之间是没有关系的。也就是说一个线程在读另一个线程还是在修改或者在写在删除这两边的并发冲突还是没有解决。总结一下也就是我们所谓的写写冲突在某些条件下被解决了,而读写冲突或者写不同的写相互之间的并发冲突是没有被解决的。同时其实我们可以用同同步块或者显式锁的机制作为一个大的全局锁。对一个List的任何操作都要先上这把锁,这时候List的操作就是线程安全的了。但是很可惜,这样的话它的性能就非常的低,因为所有的操作都不能并发了。

那么我们有没有一种办法,既能够保证我们的线程安全。又能让我们的并发操作是可以进行,这中间就有一个需要做一定的权衡和牺牲的地方。假如说一个List正在被修改,那么所有正在读它的人。大家是读到的新的内容,还是读到旧的内容。这一点是需要做权衡的。一个理想的解决方式就是我们使用一种所谓的类似于快照的技术。如果在一瞬间好多线程都来读。让这些读的线程都是可以并发来读的,它们读到的都是当前这个时间点的List的快照数据。所有对这个List的修改让它们也可以进来操作。它们操作的是一个新的List,等到新的List整个都处理完了操作完了再把它替换掉旧的List,也就是说当修改操作完成的时候。这个时间点再往后所有并发进来的访问并发进来的读,大家读到的是新的一个快照。

CopyOnWriteArrayList
核心改进原理:

  1. 写加锁,保证不会写混乱
  2. 写在一个 Copy 副本上,而不是原始数据上(GC young 区用复制,old 区用本区内的移动)

读写分离
最终一致

插入元素
1、插入元素时,在新副本操作,不影响旧引用,why?
添加元素时,拿到类的锁,将这个添加方法锁住。只有把它锁上了才能够做到我们的写不冲突。锁完以后我们先拿到当前的ArrayList里面的数组。拿到以后知道它的长度,接着创建一个新的数组。把这个旧数组的所有的元素复制到新的数组上。复制完了以后在它的尾部添加上我们当前要往List里面添加的元素。 那么新的数组newElements现在相当于是旧数组里面的所有的元素加上我们现在要往里添加的元素新数组。最后再把新数组set进我们当前List里。替换掉原先旧的数组elements。然后返回true再把锁给释放掉。在整个过程中旧数组elements是没有变化的。这样的话也也就意味着如果我们现在有读,不管你是用迭代器的方式读还是for循环的方式读不管你怎么读,你读的是旧的数组。那么怎么读它都是不变的。从而就帮我们消灭掉了并发的安全问题。删除元素
2、删除元素时
1)删除末尾元素,直接使用前N-1个元素创建一个新数组。
2)删除其他位置元素,创建新数组,将剩余元素复制到t新数组。

remove方法和add方法它用到的锁是同一个锁,这样的话这一把锁就能同时卡住我们对它的添加和修改。这个并发操作也会在这被我们的锁卡住变成同步的。所有读的操作都不会被写的操作所影响。所以读就是并发安全的,写也因为这样一个全局的锁所以是并发安全的。

读取
3、读取不需要加锁,why?
get就是直接get它里面这个数组当前这个位置的元素。没有加锁加判断,非常高效方便。
迭代器
4、使用迭代器的时候,直接拿当前的数组对象做一个快照,此后的 List 元素变动,就跟这次迭代没关系了。
想想:淘宝商品 item 的快照。商品价格会变,每次下单都会生成一个当时商品信息的快照。

这里解决的是“底层数组元素读写操作”的问题,但是并不能保证这时候来并发操作数组元素本身内部的属性也是线程安全的,除非被读写的这个数组元素本身也是一个线程安全的类。这里不能混淆了。

HashMap

HashMap底层结构图
基本特点:空间换时间,哈希冲突不大的情况下查找数据性能很高

用途:存放指定 key 的对象,缓存对象

原理:使用 hash 原理,存 k-v 数据,初始容量16,扩容x2,负载因子0.75
JDK8 以后,在链表长度到8 & 数组长度到64时,使用红黑树(红黑书本身维护操作也需要一定的开销)

安全问题:
1、写冲突
2、读写问题,可能会死循环
3、keys()无序问题

HashMap的原理就是空间换时间,把一个比较大的对象的范围比如把所有对象我们作为key取它的HashCode。然后通过取模的方式把它压缩到一个较小的范围。再用这些较小的槽来存放我们的对象。在每个槽里我们就放了很多的key value组成的这样一个一个的小集合。每个小集合我们叫一个Entry。当我们填充的数据的总量不大的情况下,这时候大家比较分散。我们所谓的Hash的冲突也不严重。这个时候整个HashMap的性能就比较好。如果我们往里填入的数据量比较大,Hash的冲突比较严重,两个对象最后取模都是1都在这里怎么办。解决Hash冲突有很多办法,典型的像Java里面用的是链表法(开放寻址法、二次Hash法)。这样我们把Hash冲突的数据都放在同样的一个位置一个槽里,这时候当我们获取其中某个key对应的value的时候,先通过HashCode取模到这个槽上拿到这条链表。再循环这条链表。比较每个key的值跟我们要的key的值是一样的。就把这个Entry拿出来。这里面就有它的value。所以我们对于HashMap需要平衡,它尽量不要被装满了。装到一小半。总之不能太满。这个时候它的性能是相对比较好的。一旦满了这种Hash冲突必然就会很严重。比如8个槽要放9个数据,这个时候一定会导致这里面至少有一个槽有两个数据。所以我们整体来说,HashMap就是用空间来换时间。当我们要放的数据量很大的时候没我们需要的空间尽量也非常大。就让大家尽量地都不要充满。所以这里就有一个所谓的负载因子默认是0.75。当现在放入数据的时候发现整个所有的槽的数量和元素的数量这两个比如果到达了0.75,这个时候就要进行扩容。一般情况下我们会认为这个负载因子越低Hash冲突的情况就越小。但是换过来负载因子越低也就意味着我们大部分时间内都有很多的空闲的槽没被使用上,一些内存空间被我们浪费掉了。所以负载因子同时又不能太小。太小了性能很好但是空间浪费多。太大了空间浪费的没那么严重但是性能可能就下降了。还有就是初始容量太大也不好。太大了假如说我们往里就放了几个数据。这时候也会造成了大量的空间浪费。太小了也会造成扩容的太频繁。

LinkedHashMap

基本特点:继承自 HashMap,对 Entry 集合添加了一个双向链表

用途:保证有序,特别是 Java8 stream 操作的 toMap 时使用。(插入顺序和访问顺序,访问顺序可以被用来做缓存里面用到的LRU策略)

原理:同 LinkedList,包括插入顺序和访问顺序

安全问题:
同 HashMap

ConcurrentHashMap-Java7 分段锁

既然HashMap是线程不安全的,存在着并发读写的这种数据的不安全。特别是容易造成死锁。那么我们有什么办法能够改进这个线程不安全的状态呢?我们可以在所有的Map的读写操作的外层加一个大锁,全都同步卡住。这样的话一定是线程安全的但是性能也不高。因为所有操作线程都需要在这个大的锁上,在这个同步块上做排队。相当于串行地来处理了。多线程就完全没有用上。所以在Java7里面就做了大锁的改进。使用了分段锁。

分段锁结构
分段锁结构2
分段锁
默认16个 Segment,降低锁粒度。
concurrentLevel = 16

想想:
Segment[] ~ 分库
HashEntry[] ~ 分表

分段锁的原理很简单,它首先在我们的一个大的HashMap里面定义了16个段,相当于在这一层先做一层一次Hash。Hash完以后这16段里每个段再有很多的槽。每个段里相当于是一个完整的HashMap。这样的话进行任何一个操作的时候就先要进行一次Hash和取模。先判断它在哪个段里。接下来的操作我们就只需要锁这个段就可以了。这样的话我们并行起来在并发多线程执行的时候,最多允许16个线程并发地来操作这16个段。每个段上一个小锁。而不是原先的一个整个的HashMap大锁,这样的话我们每个锁的范围粒度就变小了。整个Map的并发的性能就提升了。当然前提是我们能够把我们这些并发的操作尽量地打散到这16个小锁上去。也就是说在最恶劣的情况下,如果所有的操作都打到其中一个段上去了。 跟原先的HashMap加一个大锁没有任何区别。因为这16个段的大小是可以调整的,而且它完全关系着我们当前这个Map它的并发处理能力。所以在ConcurrentHashMap里把16这个因子就叫它的并发级别。concurrentLevel并发级别。如果我们有更多的线程要并发来处理这个Map。我们可以增大这个级别加更多的段。然后锁的粒度就进一步小了。

ConcurrentHashMap-Java8

Java8 ConcurrentHashMap结构
Java8 ConcurrentHashMap结构2
Java 7为实现并行访问,引入了 Segment 这一结构,实现了分段锁,理论上最大并发度与 Segment 个数相等。
Java 8为进一步提高并发性,摒弃了分段锁的方案,而是直接使用一个大的数组。
why?

主要是基于我们各种无锁这种并发的技术成熟,以及JDK8里面我们在HashMap规模稍微大一点的时候会把里面的链表改成红黑树。基于这样一些操作,JDK8也对ConcurrentHashMap做了一些改进,把Segment去掉了。首先因为很可能是红黑树,红黑树天然地我们就可以让不同的线程只要去操作它不同的树的这样一些分支上去。第二点是我们可以采用乐观锁CAS的一些这样的无锁技术。在大部分的情况下都可以不需要加锁也能对我们的数据操作成功。大大地降低了加锁带来的这些性能开销。

并发集合类总结

并发集合类总结
ArrayList 和 LinkedList 并发读写不安全,使用副本机制来改进。cow
HashMap 和 LinkedHashMap 并发读写不安全,使用分段锁(segment 降低锁的粒度)或CAS锁来改进。

并发编程

线程安全操作利器 - ThreadLocal

ThreadLocal 原理图
ThreadLocal 重要方法
可以看做是 Context 模式,减少显式传递参数

  • 线程本地变量
  • 场景: 每个线程一个副本
  • 不改方法签名静默传参
  • 及时进行清理

ThreadLocal能够简化我们编程的这种技巧性的对象。造成线程不安全的原因就是多个线程操作同样的一个对象一个变量。假如说我们能够让我们多个线程操作的这些变量对象它们每一个都只针对当前自己这个线程是有效的。那么在很多场景下我们都可以把并发的安全问题给降低。减少发生冲突的可能性。ThreadLocal 就是针对这样的一些场景设计出来的一个类。就像它的字面意思一样,它是每个线程Local 本地的一些变量保存和读取的机制。也就是说每个线程当它操作一个ThreadLocal的时候,它往里写的数据和读的数据都是它自己线程的。另外一个线程来操作的时候。读和写的数据也是线程自己的。这样的话就把线程和线程之间的数据隔离开了。当一个线程使用一个ThreadLocal类型的这样一个对象的set方法set一个值的时候。它set这个值只有它自己的线程能够读到能够看到。可以在这个线程另外某个地方调用的时候通过get拿到它。通过ThreadLocal我们就可以实现了我们不用显式传参就能够把参数传递下去而且是线程安全的。

四两拨千斤 - 并行 Stream

并行Stream代码示例
多线程执行,只需要加个 parallel 即可。

并行 Stream 的操作可以简单地在原本单线程处理的代码上通过添加一个.parallel() 就把这个操作变成是一个并行多线程的线程池来处理的这样一个过程。也就是说当我们把我们对各种集合相关的一些复杂操作各种循环改写成一个 Stream,这时候默认它都是由当前执行的线程来跑。如果我们想让它变成一个能够并发执行使用到我们的多核cpu来执行的降低它整个执行的时间。你只需要简单地在上面加一个.parallel()就可以了。默认它会调用我们Java并发包里面底层线程池相关的一些实现,创建出来一个跟当前的cpu核心数一样大小的线程池来处理我们后面的比如map相关的一些操作。所以Stream这个技术在它使用的范围内就可以把我们的单线程编程和多线程线程池的并发编程这两个编程模型统一了,而且把底层的细节都屏蔽了你看不见。

伪并发问题

  • 跟并发冲突问题类似的场景很多
  • 比如浏览器端,表单的重复提交问题
    – 1、客户端控制(调用方),点击后按钮不可用,跳转到其他页
    – 2、服务器端控制(处理端),给每个表单生成一个编号,提交时判断重复

分布式下的锁和计数器

  • 分布式环境下,多个机器的操作,超出了线程的协作机制,一定是并行的
  • 例如某个任务只能由一个应用处理,部署了多个机器,怎么控制
  • 例如针对用户的限流是每分钟60次计数,API 服务器有3台,用户可能随机访问到任何一台,怎么控制?(秒杀场景是不是很像?库存固定且有限。)

分布式缓存会详细讲

经验总结

加锁需要考虑的问题

  1. 粒度 (粒度能小就小,锁的粒度影响性能)
  2. 性能
  3. 重入
  4. 公平(一般情况下尽量让锁是公平的)
  5. 自旋锁(spinlock)cas乐观锁,90%的场景都可以一次性操作成功,降低使用锁的开销
  6. 场景: 脱离业务场景谈性能都是耍流氓

脱离业务场景谈多线程,谈并发编程安全,谈数据一致性都是耍流氓。我们要根据具体的使用场景,具体的业务代码来分析这块是否需要使用多线程相关的一些线程安全的操作,是不是需要使用锁。是不是可以用更小的锁的范围和粒度。是不是可以用乐观锁CAS之类的机制来降低我们这种锁带来的开销。

线程间协作与通信

线程与堆内存关系

  1. 线程间共享:
    • static/实例变量(堆内存)
    • Lock
    • synchronized
  2. 线程间协作:
    • Thread#join()
    • Object#wait/notify/notifyAll
    • Future/Callable
    • CountdownLatch
    • CyclicBarrier

我们线程和线程之间共享数据很简单,我们通过传递一个变量或者一个全局的static静态的一个对象都可以实现线程和线程之间的数据共享。因为我们所有的对象都是在堆上的,线程和堆是各自独立的。所以所有的线程其实是可以共享对上面的数据的。

那么线程与线程之间相互之间的状态以及行为怎么来协作,怎么来协商一致呢。最关键的就是通过我们的Synchronized同步块对我们的对象加一些锁状态,然后通过我们显式地Lock,Lock的具体的对象。通过我们之前学习的wait notify这样的一些机制然后实现他们相互之间的这种通知的。基于wait在上面封装一层我们就有了join,等待另一个线程执行完了再返回我们再接着往下走。

然后线程和线程之间有调用关系,调用完了以后想拿到另外一个线程里的数据,这时候我们就需要 Future、Callable、Completable Future这样的一些类。

最后我们来控制各个线程它们执行的过程中能够在一些点做聚合。最后当我们运行多个线程的时候,我们需要这多个线程能够在一些条件的点下进行聚合。再继续协同地进行下一步的处理。我们希望能够控制我们当前这段业务代码能够用资源和并发的信号量来保护它们。来控制他们的并发行为。我们就可以引入我们的并发的工具类。我们的信号量 Semaphore、CountdownLatch、CyclicBarrier这样的一些工具类来处理。

不同进程之间有哪些方式通信?

mq、rpc、http

并发编程常见面试题

自己看面试题

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值