多线程
多线程
基本概念
- CAS (Compare-And-Swap,比较并交换),是一种用于实现多线程同步的原子操作。主要原理:1.比较内存中的某个位置的当前值和预期值 2.交换如果当前值与预期值相等,则将该位置的值更新为新值,否则不进行任何操作。
- 因为是原子操作所以在多线程中很高效。可以实现无锁编程,避免了上下文切换的开销。
- 缺点是如果CAS操作失败后,通常会进行自旋,消耗CPU资源。
- 自旋:当一个线程尝试获取锁但是所以经被其他线程获取时,该线程不会进入睡眠模式,而是会在一个循环中不断的检查锁的状态,直到锁被释放,这种方式叫做自旋。
- 优点是:低开销,可以避免线程上下文切换的开销,因为线程不会进入睡眠状态。适用于短时间的锁定,因为时间段,自旋等待的开销可能比线程切换开销更低。
- 缺点是:CPU消耗搞,自旋不断等待占用CPU,也不适合长时间锁定,因为线程会长时间占用CPU资源进行无效的检查。
- 并发:一段时间内进行 并行:同一时刻同时进行
原子操作
处理器如何实现原子操作的
- 使用总线锁来保证原子性:如果多个处理器同时对共享变量进行读改写操作(例如i++),共享会被多个处理器同时进行操作,导致共享变量的值与期望不同。 因为他们会从自己的缓存中读取变量i,然后分别进行+1,之后分别写入系统内存中
- 处理器总线锁:使用了处理器提供的LOCK#信号,当一个处理器在总线上发出这个信号,其他处理器的请求将被阻塞住。从而实现独占共享内存。
- 使用缓存锁:总线锁会导致其他处理器不能处理其他内存地址的数据,我们只需要保证对某个内存地址的操作是原子的就行。
- 频繁使用的内存会缓存在处理器的L1、L2、L3高速缓存中。
- 缓存锁定:缓存锁定是某个CPU对缓存数据进行更改时,会通知缓存了该数据的该数据的CPU抛弃缓存的数据或者从内存重新读取。
多线程就一定快吗?
不一定,因为线程切换涉及到上下文切换和线程创建的开销
如何减少上下文的切换次数
- 无锁并发编程:避免使用锁,利用将数据的ID按照Hash算法取模运算,不同线程处理不同段的数据
- CAS算法:不需要加锁
- 使用最少线程:避免创建不需要的线程
- 协程:单线程中实现多任务的调度,并且再单线程中维持多个任务间的切
资源限制
并发编程中,如果多线程占用的资源超过系统资源的限制,实际上仍然是串行执行的,而且因为有上下文切换的影响,反而会更慢
如何避免死锁
- 避免一个线程同时获得多个锁
- 避免一个线程在锁内同事占用多个资源,尽量保证每个锁只占用一个资源
- 尝试使用定时锁
lock.tryLock(timeout)
来替代内部锁机制 - 对于数据库锁,枷锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况
常用的线程分析工具
vmstat
常用指令,具体的参数自己去搜,这里主要看的是cs指标,代表每秒上下文切换次数
vmstat [delay] [count] 后面参数可选
每隔delay秒输出一次统计信息,总共输出count次
vmstat -s 显示系统的累计统计信息
-d 显示统计信息
-p +指定分区 显示指定分区的统计信息
-a 显示活动内存和非活动内存的信息
-m 显示slabinfo信息(`slabinfo` 是 Linux 内核中用于显示 slab 分配器(slab allocator)信息的工具)
-t 在输出中添加时间戳
Java中的多线程
当在一个JVM进程里面开多个线程时,这些线程被分成两类:守护线程和非守护线程。默认开的都是非守护线程。在Java中有一个规定:当所有的非守护线程退出后,整个JVM进程就会退出。意思就是守护线程“不算作数”,守护线程不影响整个 JVM 进程的退出。例如,垃圾回收线程就是守护线程,它们在后台默默工作,当开发者的所有前台线程(非守护线程)都退出之后,整个JVM进程就退出了。
Wait方法为什么不定义在Thread中?
Wait释放的锁是写在Java对象头中,所以是写在Object中而非当前线程
锁的分类和对比
Java中锁存在四种状态
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
锁可以升级但是不能降级,这种设定能够提高获得锁和释放锁的效率
偏向锁
大多数情况下锁不仅不存在多线程竞争,而且总是由同意线程多次获得。
当一个线程访问同步块并且获得锁时,会在对象头和栈帧中的锁记录里面存锁偏向的线程ID,以后该线程进入/推出额同步块块时,不需要进行CAS来进行枷锁和解锁,只需要测试对象头Mark Word里是否存储着这项当前线程的偏向锁。
如果测试失败就看偏向锁的标识是否为1,1是偏向锁,如果不是就用CAS竞争锁,否则尝试使用CAS将偏向锁设置为当前线程
偏向锁的撤销
- 偏向锁只有其他线程尝试竞争偏向锁时持有偏向锁的线程才会释放锁。
- 偏向锁的撤销,需要在全局安全点(在这个事件电商没有正在执行的字节码),会先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否或者,如果线程处于不活跃状态,则将对象头设置成无锁状态;如果线程仍然存活,则拥有偏向锁的栈会被执行,便利偏向对象的锁记录,要么重新偏向其他线程,要么恢复到无锁或者标记独享不适合作为偏向锁,最后唤醒暂停的线程。
轻量级锁
加锁
线程在执行同步块之前,JVM会在当前线程的栈帧中创建用于存储锁记录的空间,并且将对象头中的Mark Word复制到锁记录中(Displaced Mark Word替)。然后线程尝试使用CAS将对象头中的Mark Word替换为只想所记录的指针。如果成功,当前线程获得锁,如果失败,尝试使用自旋来获得锁。
解锁
会使用原子的CAS操作将Displaced Mark Word替换回对象头。如果成功则说明没有竞争发生,如果失败,标识当前锁存在竞争。锁会升级成重量级锁。
volatile
volatile是轻量级的synchronized,保证了共享变量的可见性,同时不会引起上下文的切换和调度。
但是i++不能保证原子性的,因为i++是读写两次操作。
JVM中并没有要求64位long/double写入是原子的。所以多线程读取时又可以读到的是”一半”的值。这个时候就需要使用volatile了
主要功能
- 保证单词写入/读入原子性
- 内存可见性
- 禁止重排序
前置概念
volatile是如何实现的?
- 转变成汇编语言之后会多一个Lock前缀,这个前缀会将当前处理器缓存行的数据写回系统内存,同时其他CPU中缓存了该内存地址的数据无效。修改volatile变量会强制将修改之后的值刷新到内存中同时导致其他线程中的该变量值失效。
- 处理器会根据MESI(修改、独占、共享、无效)控制协议去维护内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。
可见性:
修改volatile变量会强制将修改之后的值刷新到内存中
同时导致其他线程中的该变量值失效。
有序性:遵循happen-before
内存屏障:JVM通过内存屏障来实现的
内存屏障
CPU防止代码进行重排序而提供的指令。Unsafe提供了以下的内存屏障方法
//内存屏障,禁止load操作重排序。屏障前的load操作不能被重排序到屏障后,屏障后的load操作不能被重排序到屏障前
public native void loadFence();
//内存屏障,禁止store操作重排序。屏障前的store操作不能被重排序到屏障后,屏障后的store操作不能被重排序到屏障前
public native void storeFence();
//内存屏障,禁止load、store操作重排序
public native void fullFence();
重排序问题
执行程序时,为了提高性能编译器和处理器常常会对执行进行重排序。
- 编译器优化的重排序:不改变单线程语义的情况下,可以重新安排语句的执行顺序
- 指令级并行的重排序:现代处理器采用了指令级并行技术来讲多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,是的加载和存储操作看上去可能时再乱序执行
as-if-serial
多线程程序中的重排序,编译器和CPU只能保证每个线程的线程内部之间都是“看似完全串行的”,但是多个线程会相互读取和写入共享的变量不会进行考虑。
happen-before
保证一个线程的执行结果对另一个线程可见。
synchronized
是可重入的吗
可重入(Reentrant)是指在多线程环境中,一个函数可以被多个线程同时调用而不会引起任何问题。
是可重入的,因为synchronized关键字是基于JVM内部的监视器锁,这种锁是依赖于对象头中的标记字段来管理锁的状态。当线程第一次获得锁时,他的线程ID会被记录在对象头的标记字段中,并且计数器设置为1,如果同一线程需要再次进入由自己持有锁的synchronized块时,计数器就会+1,当synchronized块时,计数器-1。当计数器回到0时,锁才真正被释放,此时其他线程可以尝试获取这个锁。
synchronized可以锁的类型
- 对于普通同步方法,锁的是实例对象
- 对于静态同步方法,锁的是当前类的Class对象,包括这个类的所有对象
- 对于同步方法块,锁的是Synchornized括号里的对象
实现和原理
- synchronized用的锁是存在Java对象头里的,如果对象是数组类型,则虚拟机用三个字宽存储对象头。
- 对象头会随着锁标志位的变化而变化
Java内存模型的基础
并发编程中常常需要解决线程之间如何进行通信和如何进行同步。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
共享内存是线程共享程序的公共状态,通过读写内存的公共状态来进行通信。消息传递则是线程之间必须通过发送消息来显示的进行通信。
Java中使用的是共享内存模型。
他们之间的通信需要修改共享变量,然后由另一个去读取共享变量来实现。
JDK5 开始,Java使用JSP-133内存模型,使用happens-before:前一个操作的结果对后一个操作可见
CountDownLatch
什么是CountDownLatch?
CountDownLatch 是通过一个计数器来实现的,计数器的初始值就是线程的数量,每当一个线程执行完毕之后,计数器的值就-1,然后在闭锁上等待的线程就可以恢复工作了。主要使用场景:
- 用于等待多个线程完成一个整体的前提任务
实例:在进行业务之前将两个数据库中的数据进行同步(非集群的数据库)import java.util.concurrent.CountDownLatch; public class DatabaseSync { public void syncDatabase1() { // 这里是同步数据库1的代码 } public void syncDatabase2() { // 这里是同步数据库2的代码 } public void sync() throws InterruptedException { CountDownLatch latch = new CountDownLatch(2); new Thread(() -> { syncDatabase1(); latch.countDown(); }).start(); new Thread(() -> { syncDatabase2(); latch.countDown(); }).start(); // 等待两个数据库同步操作完成 latch.await(); System.out.println("Both databases have been synchronized."); } public static void main(String[] args) throws InterruptedException { new DatabaseSync().sync(); } }
JUC包
CompletableFuture
Future的缺点:不支持异步任务的编排,同时get方法是阻塞调用
完全可控的Fututure
使⽤线程池时,我们应该尽量避免将耗时任务提交到线程池中执⾏。对于⼀些⽐较耗时的操作,如
⽹络请求、⽂件读写等,可以采⽤ CompletableFuture 等其他异步操作的⽅式来处理,以避
免阻塞线程池中的线程
示例代码:
CompletableFuture<Map<String, Map<String, Object>>> future = CompletableFuture.supplyAsync(() -> adminClient.distributedInstance());
CompletableFuture源码分析
- runAsync不允许返回值,适合需要一步操作但是不关心返回结果
- supplyAsync需要返回值,适合需要返回值的异步操作
- thenApply、thenAccept、thenRun、whenComplete 可以对结果进行进一步处理
- 异常处理使用handle
- 合并future结果,thenCompose是链接两个CompletabelFuture,并将前一个结果作为下一个任务参数,thenCombine会将两个任务都结束之后,将两个任务的结果合并,并行执行
- allOf等待所有的执行完成之后再调用
默认使用的是ForkJoinPool.commonPool作为执行器,这个线程池全局共享,可能会被其他任务占用
ConcurrentHashMap
线程安全的HashMap,多线程情况下HashMap进行put操作会进入死循环。而使用HashTable效率又很低,因为当一个线程访问HashTable的同步方法,其他线程也访问时,会进入阻塞或轮询状态,所有的线程都必须竞争同一把锁。而我们只需要有多把锁,每一把锁都只锁住某一部分数据即可。这就是ConcurrentHashMap使用的锁分段技术。JDK1.7使用的是分段的数据+链表实现的,JDK1.8使用的数据结构跟HashMap一职,数组+链表/红黑树。使用的是Node数组+链表+红黑树,通过synchronized和CAS操作来帮正线程安全
实现原理 具体看源码
1.7
Segment数组(不可扩容) 作为分段锁,是可重入锁,对其中的一部分加锁
1.8
使用的是Node数组+链表/红黑树,Node只适用于链表的情况,而红黑树需要TreeNode。使用Node+CAS+synchronized来保证线程安全
常用的api
get
先进性一次散列,然后使用这个散列值定位到Segment,再进行散列定位到元素。
get不需要加锁,因为get方法中使用的共享变量都顶i成volatile类型,额能够在线程之间保持可见性。保证不会读到过期的值,但是只能被单线程写(如果写入的值依赖原值)
根据happen before原则,对volatile字段的写是优先于读的。
put
对共享变量进行写入操作,为了线程安全必须加锁。
先定位到Segment,之后再Segment里进行操作,所以只需要锁住一个Segment即可
扩容机制:只会对某个segment进行扩容。
count
先尝试不加锁来统计各个Segment的大小,如果两次中出现了不同的数值,就采用加锁的方式来统计所有Segment大小。原理是格局modCount变量,put、remove、clean方法都会把modCount+1
CopyOnWriteArrayList
线程安全的 List,用来替代Vector
Vector的核心思想是每次访问都上锁,使用synchronized进行加锁,会导致性能很差
而CopyOnWriteArrayList则是使用了跟读写锁相似的思想,读读不互斥。写不会堵塞读取操作,只有写写才会出现互斥,核心思想是写时复制:不会直接修改原数组,而是先创建底层数组的副本,对副本进行修改,修改完之后再将修改后的数据赋值回去。
ConcurrentLinkedQueue
线程安全的队列,是非阻塞实现的
实现原理
入队使用CAS算法实现的
- 定位尾节点
- 使用CAS算法来不断尝试将节点加入队列:如果尾节点的next是null表示已经是尾节点了,如果不是说明其他县城更新了尾节点,需要重新或如当前队列的尾节点。
出队
先获得头节点的元素,判断头节点元素是否为空,如果为空就是已经被别的线程取走,如果不为空就用CAS尝试出队
BlockingQueue
有多种实现
值得注意的时Pirority和Delay
阻塞队列,当队列满时,队列会阻塞插入元素的线程,之道队列布满。
当队列为空时,获取元素的线程会等待队列变成非空
常用于成缠着消费者问题
实现原理
通知模式实现
通知模式是生产者往满的队列中添加队列时会阻塞住生产者。当消费者消费了一个队列中的元素后,会通知生产者当前的队列可用
CountDownLatch
示例代码
public class Main {
static CountDownLatch countDownLatch = new CountDownLatch(2);
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
System.out.println("1");
countDownLatch.countDown();
System.out.println("2");
countDownLatch.countDown();
}).start();
countDownLatch.await();
System.out.println("3");
}
}
CyclicBarrier
同步屏障
功能是让一组线程到达一个屏障(同步点)时被阻塞,直到最后一个线程到达屏障时才开门,所有被阻塞的线程才会继续运行。
与CountDownLatch的区别:countdownlatch只能使用一次,计数器无法重置。cyclicbarrier可以多次重复使用,当所有的线程到达同步点之后屏障会重置。
CyclicBarrier:用于让一组线程互相等待,直到所有线程都到达某个屏障点,然后所有线程再继续执行。可以重用。
CountDownLatch:用于一个或多个线程等待其他线程完成某些操作。不能重用。
二者使用方法相似
如何选择:
简单的一次性同步任务可以使用countdownlatch,例如初始化一些配置
多阶段反复同步线程或者并发任务需要分阶段执行,每个阶段都需要等待所有的线程完成,选择cyclicbarrier
Semaphore
信号量,控制同时访问特定资源的线程数量。
Exchanger
进行线程之间协作的工具类。提供一个同步点,两个线程可以在这个同步点交换彼此的数据。
原子类
Atomic包
Java中的线程池
实现原理
- 判断核心线程池里的线程是否都在执行任务,如果不是则新建一个工作线程来执行任务;如果都在执行任务,则进入下一个流程
- 判断工作 队列是否已经满,如果没满,将新提交的任务存储在这个工作队列里,如果满了,进入下一个流程
- 判断线程池中 当线程是否都在工作中,如果没有就创建一个新的工作线程来执行任务,如果已经满了,则交给饱和策略来处理这个任务
核心参数
- 核心线程数:任务队列没满时可以同时执行的最大线程数
- 最大线程数:任务队列满时,可以同时运行的线程数
- 线程空闲时间:线程数量超过核心线程数时,多余的空闲线程再中止前等待新任务的最长时间
- 时间单位
- 任务队列:
- 线程工厂
- 拒绝策略:当任务无法提交到线程池时的处理策略。
AbortPolicy
:抛出RejectedExecutionException
异常(默认策略)。CallerRunsPolicy
:由调用线程处理该任务。DiscardPolicy
:直接丢弃任务。DiscardOldestPolicy
:丢弃队列中最旧的任务,然后重新提交新任务。
任务执行顺序
- 当前运行中的线程数小于核心线程数,就新建一个线程来执行任务,即使线程池中存在空闲线程
- 如果大于等于核心线程数,但是小于最大线程数,就把任务加入到任务队列中
- 如果队列已满,但是线程数小于最大线程数,就新建一个线程来执行任务
- 如果新创建线程会导致当前运行中的线程数大于最大线程数,就会调用拒绝策略
生产者-消费者模型:
一个内存队列,多个生产线程往内存队列中放数据,多个消费者线程从内存对俄中取数据。
- 内存队列本身需要枷锁,才能实现线程安全
- 阻塞。当内存队列满了,生产者被阻塞,内存队列为空时消费者被阻塞。
- 双向通知:消费者被阻塞之后,生产者放入新数据,要通知消费者,反之要通知生产者。
Unsafe
主要功能
- 内存屏障
ReenTrantLock
实现原理
实现是一种自旋锁,使用循环调用CAS操作来进行加锁。
线程同步的方法
- Synchronized
- ReentrantLock
- CountDownLatch
- CyclicBarrier
- Semaphore
- Wait和No