Java并发编程
1. 几个问题
1.1 什么是多线程并发编程
并发是指同一个时间段内处理多个任务,单CPU时所有任务都是并发执行的。
1.2 为什么要提出多线程并发编程
单CPU时代多线程编程意义不大,而且线程的频繁切换会带来额外的开销。但随着多核CPU的到来,以及应用系统在高并发方面的瓶颈,高并发编程必不可少。
1.3 引入多线程并发编程会带来哪些问题
线程安全:多个线程同时读写一个共享资源且没有任何同步措施时,导致脏数据或其他不可预见的问题。
1.4 多线程并发编程中的三大概念
1.4.1 原子性
一(多)个操作要么全部执行要么全不执行,中途不会被打断。(和数据库中的事务概念类似)
例如byte/short/int/char/boolean/float读写具有原子性;类似++i的操作不具原子性。
1.4.2 可见性
一个线程对某变量的修改对其他线程来说是可见的,即其他线程知道值的修改情况。
1.4.3 有序性
程序执行按照代码的顺序执行。
代码重排是编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。在单线程中,as-if-serial语义规范用于保证重排序不会影响单线程程序的执行结果。
这三个概念是保证线程安全的基石。
2. JMM
2.1 Java内存模型
Java内存模型如上,需要注意以下几点:
- 所有线程的共享变量都保存在主内存中
- 每个线程都有自己的工作内存,其中保存主内存中共享变量的副本
- 线程对共享变量进行修改时,先会对工作内存中的值进行修改,之后在传入主内存中
显然,这种值更新机制在多线程的场景下会产生很大的问题,典型的例子就是两个线程同时对同一共享变量进行修改,由于上面的步骤,会导致主内存中的值只是后提交线程的修改结果,而前一进程的修改遗失(不满足可见性)。
2.2 JMM下如何保证线程安全?
2.2.1 原子性
通过加锁保障同步代码块的原子性
2.2.2 可见性
锁机制、volatile、final关键字保障可见性
2.2.3 有序性
由happen-before原则保障
2.3 happen-before
Happen-before也称先行发生原则,定义了两项操作间的偏序关系,是判断数据是否存在竞争的重要手段。
JMM 将 happens-before
要求禁止的重排序按是否会改变程序执行结果分为两类。对于会改变结果的重排序 JMM 要求编译器和处理器必须禁止;对于不会改变结果的重排序,JMM 不做要求。
一些规则:
- 程序次序规则:一个线程内写在前面的操作先行发生于后面的。
- 管程锁定规则:
unlock
操作先行发生于后面对同一个锁的 lock 操作。 volatile
规则:对volatile
变量的写操作先行发生于后面的读操作。- 线程启动规则:线程的
start
方法先行发生于线程的每个动作。 - 线程终止规则:线程中所有操作先行发生于对线程的终止检测。
- 对象终结规则:对象的初始化先行发生于
finalize
方法。 - 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。
与as-if-serial的区别:都是为了在不改变程序执行结果的前提下尽可能提高程序执行并行度,as-if-serial
保证单线程程序的执行结果不变;happens-before
保证正确同步的多线程程序的执行结果不变。
2.4 内存屏障
happen-before的底层实现采用了内存屏障,其本质上是一种特殊的指令。
编译器的内存屏障禁止对指令进行重排序;CPU的内存屏障是CPU提供的指令,可以由开发者显式调用;
在理论层面,内存屏障可分为四种:
(1) LoadLoad:禁止读和读的重排序。(LL)
(2) StoreStore:禁止写和写的重排序。(SS)
(3) LoadStore:禁止读和写的重排序。(LS)
(4) storeLoad:禁止写和读的重排序。(SL)
JDK中定义了三种内存屏障函数loadFence()、storeFence()、fullFence(),其中
loadFence()=LoadLoad+LoadStore
storeFence()=StoreStore+LoadStore
fullFence()=loadFence()+storeFence()+storeLoad()
2.5 volatile
JMM 为 volatile
定义了一些特殊访问规则,当变量被定义为 volatile
后具备两种特性:
- 保证变量对所有线程可见(可见性)
当一条线程修改了变量值后,其他线程的工作内存中的值立即失效。
-
禁止重排序
在volatile写前加SS,写后加SL;
在volatile读后加LL和LS
-
无法保证原子性
例如对与++i操作,volatile无法保证拿到的值中途是否被修改
使用场景:
- 运算结果不依赖变量的当前值
- 一写多读,只有一个线程会修改值
2.6 final保证可见性
写 final
域重排序规则
禁止把 final
域的写重排序到构造方法之外,编译器会在 final
域的写后,构造方法的 return
前,插入一个 Store Store
屏障。确保在对象引用为任意线程可见之前,对象的 final
域已经初始化过。
读 final
域重排序规则
在一个线程中,初次读对象引用和初次读该对象包含的 final
域,JMM 禁止处理器重排序这两个操作。编译器在读 final 域操作的前面插入一个 Load Load
屏障,确保在读一个对象的 final
域前一定会先读包含这个 final
域的对象引用。
3. 锁机制
3.1 CAS乐观锁
读操作不上锁
写操作时判断数据是否被修改,如果被修改那么读取数据重复该过程;否则写入新值。这个操作合成一个原子操作,即CAS(Compare And Set)
应用:
- atomic包下的原子类
- 锁的底层AQS队列出入栈的CAS操作
ABA问题:值未变不代表未被访问,可通过加版本号的方式解决。
3.2 Synchroized关键字
3.3 Lock锁原理
3.4 ReentrantLock和ReentrantReadWriteLock
4. 同步工具
4.1 Semaphore
4.2 CountDownLatch
4.3 CyclicBarrier
5. 线程池
5.1 为什么需要线程池?
-
降低资源消耗:通过重复利用已有的线程降低线程创建和销毁的开销
-
提高响应速度:任务到达时不必等待线程的创建
-
提高线程的可管理性
5.2 Java中的继承结构
5.3 ThreadPoolExecutor
5.3.1 组成
-
corePoolSize:核心线程数
-
maximumPoolSize:线程池中最大允许的线程数量(核心线程数+非核心线程数)
-
keepAliveTime:允许非核心线程闲置的时长
-
TimeUnit:keepAliveTime的时间单位
-
workQueue:线程池中的任务队列
ArrayBlockingQueue:基于数组的有界阻塞队列
LinkedBlockingQueue:基于链表的有界阻塞队列
SychronousQueue:不存储元素的阻塞队列
PriorityBlockingQueue:具有优先级的无界阻塞队列
-
threadFactory:用于设置创建线程的工厂
-
RejectExecutionHandler:拒绝策略
AbsortPolicy:直接抛出异常
CallerRunsPolicy:调用线程所有者来处理该任务
DiscardOldestPolicy:丢弃最早任务,运行此任务
DiscardPolicy:不处理,丢弃
5.3.2 工作流程
-
判断当前核心线程池是否已满,不满则为任务分配一个线程;
-
否则,判断队列是否已满,不满则将任务加入阻塞队列;
-
否则,判断线程池是否已满最大容量,不满则在线程池中创建一个线程来执行任务;
-
否则,按拒绝策略来处理任务;
5.4 Executors
JDK提供了一些已定义的线程池供开发者使用
5.4.1 newFixedThreadPool
固定线程数的线程池,任务队列采用的是无界队列
5.4.2 newCacheThreadPool
大小无界的线程池,核心线程数为0,最大线程数为Integer.MAX_VALUE,任务队列采用的是无容量的SynchronousQueue队列
5.4.3 newSingleThreadPool
用于执行单个线程任务,适用于顺序执行的任务。核心线程和最大线程数都为1,任务队列采用的是无界链表阻塞队列LinkedBlockingQueue
5.4.4 newScheduledThreadPool
用于给定延迟、定期执行任务。核心线程数自定义,最大线程数为Integer.MAX_VALUE,任务队列采用延迟队列DelayWorkQueue
5.4.5 使用规范
一般不建议在开发中使用Executors来创建线程池,而是通过ThreadPoolExecutor来显式创建线程池,因为这样可以让使用者明确线程池的运行规则,避免资源耗尽的风险。
原因:
newFixedThreadPool和newSingleThreadPool由于采用的是无界队列,因此堆积的请求处理可能会耗费非常大的内存,导致OOM。
newCacheThreadPool和newScheduledThreadPool由于线程最大数是Integer.MAX_VALUE,同样可能因为队列中堆积大量的线程而导致OOM。