基本概念
多线程业务场景
- 异步任务
1、用户注册后的异步通知,短信/邮箱
2、异步记录日志
- 定时任务
定期备份日志、数据库
- 分布式计算
分片计算/Hadoop的map-reduce
- 服务器编程
Servlet编程模型
进程、线程、协程
- 基本概念
进程: 本质上是一个独立执行的程序,进程是操作系统进行资源分配和调度的基本概念,操作系统进行行资源分配和调度的一个独立单位
线程:是操作系统能够进⾏运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。 ⼀个进程中可以并发多个线程,每条线程执行不同的任务,切换受系统控制。
协程: 又称为微线程,是⼀种⽤户态的轻量级线程,协程不像线程和进程需要进行系统内核上的上下文切换,协程的上下文切换是由⽤户⾃⼰决定的,有自⼰的上下文,所以说是轻量级的线程,也称之为用户级别的线程就叫协程,⼀个线程可以多个协程,线程进程都是同步机制,⽽协程则是异步。 Java的原生语法中并没有实现协程,⽬前python、Lua和GO等语言支持
- 关系
一个进程可以有多个线程,它允许计算机同时运行两个或多个程序。线程是进程的最⼩执行单 位,CPU的调度切换的是进程和线程,进程和线程多了了之后调度会消耗大量的CPU,CPU上真正运行的是线程,线程可以对应多个协程
- 协程的优缺点
优点:
非常快速的上下文切换,不⽤系统内核的上下文切换,减⼩开销 单线程即可实现高并发,
单核CPU可以支持上万的协程 由于只有⼀个线程,也不存在同时写变量的冲突,在协程中控制共享资源不需要加锁
缺点:
协程⽆法利⽤多核资源,本质也是个单线程
协程需要和进程配合才能运行在多CPU上 ⽬前java没成熟的第三方库,存在⻛险 调试debug存在难度,不利于发现问题
并发与并行
- 并发
本质上只是通过cpu的切换,交替的执行任务。
但是由于切换速度过快,导致看上去是多个任务一起在执行一样
- 并行
真正的多任务执行
Java实现多线程的若干种方式
案例:多线程输出字符串
- 继承Thread类
@Slf4j
public class HelloThread extends Thread {
@Override
public void run() {
log.info("😬 我的线程号:{}", Thread.currentThread().getName());
}
}
调用
HelloThread thread = new HelloThread();
HelloThread thread1 = new HelloThread();
thread.start();
thread1.start();
- 实现Runnable接口
@Slf4j
public class HelloThread implements Runnable {
@Override
public void run() {
log.info("😬 我的线程号:{}", Thread.currentThread().getName());
}
}
调用
HelloThread thread = new HelloThread();
Thread thread1 = new Thread(thread);
Thread thread2 = new Thread(thread);
thread1.start();
thread2.start();
- Stream(JDK8+)
List<String> jobList = new ArrayList<>();
jobList.add("job1");
jobList.add("job2");
jobList.add("job3");
jobList.parallelStream()
.forEach(item -> {
System.out.println("当前线程号:" + Thread.currentThread().getName() + "\t\t" + item);
});
输出结构如下
当前线程号:main job2
当前线程号:ForkJoinPool.commonPool-worker-1 job1
当前线程号:ForkJoinPool.commonPool-worker-2 job3
- Callable和FutureTask方式
JDK1.5+支持
public class MyTaskTest {
public static void main(String[] args) {
FutureTask<Object> futureTask = new FutureTask<>(() -> {
System.out.println("通过Callable实现多线程:" + Thread.currentThread().getName());
return "返回值1:" + Thread.currentThread().getName();
});
FutureTask<Object> futureTask2 = new FutureTask<>(() -> {
System.out.println("通过Callable实现多线程:" + Thread.currentThread().getName());
return "返回值2:" + Thread.currentThread().getName();
});
Thread thread = new Thread(futureTask);
Thread thread2 = new Thread(futureTask2);
thread.start();
thread2.start();
try {
System.out.println(futureTask.get());
System.out.println(futureTask2.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
- 线程池的方式
ExecutorService executorService = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
executorService.execute(()->{
System.out.println("当前线程号:"+Thread.currentThread().getName());
});
}
executorService.shutdown();//线程池中的活跃线程处理完任务后,会自动关闭线程池
多任务并发执行实用代码
package com.zhangln.shanhaijing.thread;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.*;
@Slf4j
public class Demo01CompleteFutureMain {
public static void main(String[] args) throws InterruptedException {
// 任务列表
List<Integer> jobList = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14);
int nThreads = 3;
// 工作线程池
ExecutorService executorService = new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>());
// 接收每个任务的执行结果
List<CompletableFuture<String>> completableFutures = new ArrayList<>(jobList.size());
// 任务分派
for (int i = 0; i < jobList.size(); i++) {
try {
Integer jobId = jobList.get(i);
CompletableFuture<String> integerCompletableFuture = CompletableFuture.supplyAsync(() -> {
log.info("模拟任务执行,执行任务:{},线程号:{}", jobId, Thread.currentThread().getName());
// 睡几秒
return jobId + "任务执行结果"+Thread.currentThread().getName();
}, executorService);
// 保存本次任务执行结果
completableFutures.add(integerCompletableFuture);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
log.info("任务分派结束");
// 等待全部任务执行完毕
CompletableFuture[] completableFutures1 = new CompletableFuture[completableFutures.size()];
CompletableFuture<Void> voidCompletableFuture =
CompletableFuture.allOf(completableFutures.toArray(completableFutures1));
try {
voidCompletableFuture.get();
log.info("打印执行结果");
completableFutures.stream()
.forEach(tmp -> {
try {
log.info("执行结果:{}", tmp.get());
} catch (Exception e) {
e.printStackTrace();
}
});
} catch (Exception e) {
e.printStackTrace();
}
executorService.shutdown();
log.info("任务执行完毕");
TimeUnit.SECONDS.sleep(2);
}
}
线程状态
JDK的线程状态有6种,JVM里面有9种。
一般我们说的线程状态是JDK的线程状态
常见状态
- 新建 new:生成线程对象,未调用该对象的start方法,如 new Thread()
- 就绪 runnable:新建的对象调用start方法后,线程处于就绪状态
就绪状态的来源不仅仅是new的对象调用start方法。还可以来自运行装和阻塞状态。
所谓就绪,就是说我已经准备好了,只要CPU有空了,就可以调度我进行线程执行。
- 运行 running:获取到cpu使用权的线程,就是处于running状态
注意:在jdk中,是没有running状态的,见 Thread类的State内部枚举类。
它把runnable和running两种状态合并了
-
阻塞 blocked
- 等待阻塞
进入该状态的线程需要等待其他线程做出一定动作,如 通知、中断。此状态CPU不进行分配。
可能需要被唤醒,也可能无限等待下去。
- 同步阻塞:线程执行需要的资源被锁住,此时就处于同步阻塞状态
-
终止 terminate
线程的run执行完毕
-
线程常见API
-
sleep
线程方法,交出CPU使用权;等待预计时间后再恢复;进行阻塞状态(TIME_WAITING);睡眠结束变为就绪状态
-
yield
线程方法,暂停当前线程,让CPU执行其他线程
注意:yield操作不会让线程处于阻塞状态,而是变为就绪。只需要重新获得CPU使用权就会回到运行状态
-
join
线程方法
在主线程调用该方法,会让主线程休眠,不会释放已经持有的对象锁。
让调用join方法的线程先执行,再执行其他线程
-
wait
对象方法,释放对象的锁,进入线程的等待队列。
需要靠notify/notifyAll唤醒。或者wait(timeout后自动唤醒)
sleep和wait的区别就在于是否释放锁
-
notify
对象方法;唤醒对象监视器上等待的单个线程(随机抽取一个)
-
notifyAll
对象方法,唤醒对象监视器上等待的所有线程
-
线程状态切换
Java中保证线程安全的方式
- 加锁
synchronize/ReentrantLock
- 线程安全类
CopyOnWriteArrayList/ConcurrentHashMap
- ThreadLocal
volatile关键字
了解volatile关键字不?能否解释下,然后这和synchronized有什么大的区别
答:
volatile是轻量级的synchronized,保证了共享变量的可见性,被volatile关键字修饰的变量,如果值发生了变化,其他线程⽴立刻可见,避免出现脏读现象
volatile:保证可见性,但是不能保证原子性 synchronized:保证可见性,也保证原子性
使用场景
1、不能修饰写入操作依赖当前值的变量,⽐如num++、num=num+1,不是原⼦操作,肉眼看起来是,但是JVM字节码层⾯不⽌一步
2、由于禁止了指令重排,所以JVM相关的优化没了,效率会偏弱
什么是指令重排?
指令重排分为编译器重排和运行期重排
JVM在编译java代码,或者cpu在执行jvm字节码的是,在不改变执行结果的情况下对指令执行顺序进行调整,以优化执行效率。
- 为什么会出现脏读
JAVA内存模型简称 JMM ;
JMM规定所有的变量存在主内存,每个线程有⾃己的工作内存,线程对变量的操作都在工作内存中进行,
不能直接对主内存进行操作
使用volatile修饰变量 每次读取前必须从主内存属性最新的值 每次写入需要立刻写到主内存中
volatile关键字修饰的变量随时看到的自己的最新值,假如线程1对变量v进⾏行修改,那么线程2 是可以马上看⻅
- 什么是happens-before
因为jvm会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性。
https://www.jianshu.com/p/9464bf340234
并发编程三要素
- 原子性:即一些操作,要么全部成功,要么全部失败。执行期间不能被打断。类似于数据库的事务的概念
- 有序性:程序的执行顺序按照代码的先后顺序执行(因为处理器有可能通过指令重排进行重新排序)
- 可见性:线程A、B之间,A对共享变量的写操作,B能够马上看到
以上三特性,是并发编程中需要保证的
进程/线程调度策略
先来先服务
短作业优先
高响应比优先
时间片轮询
优先级调度
常用锁
悲观锁
当线程去操作数据的时候,总认为别的线程会去修改数据。
所以每次拿数据的时候,都会上锁。别的线程去拿数据的时候就会阻塞。
如:synchronized
乐观锁
每次去拿数据的时候都认为别人不会修改,更新的时候判断别人是否会去更新数据,通过数据版本号进行控制
如果数据被更改了,就拒绝更新。如 CAS是乐观锁,但是,严格意义来说,并不是锁。
公平锁
多个线程按照申请锁的顺序来获取锁。
简单来说,如果一个线程组中,能够保证每个线程都能拿到锁,比如:ReentranLock(底层是同步队列)
悲观锁适合写操作多的场景
乐观锁适合读操作多的场景
非公平锁
获取锁的方式是随机的,保证不了每个线程都能拿到锁。
会存在有的线程一直拿不到锁的情况。如:synchronized、ReentrantLock
ReentrantLock公平与否,由构造函数控制
可重入锁
也叫递归锁。
在外层使用锁之后,内层仍然可以使用,并且不发生死锁。
即:在调用方法A的时候,获取了锁,方法A在执行过程中,调用了方法B,在B中继续获取该锁。在可重入锁中,这种操作是被允许的。
可重入锁在一定程度上避免了死锁。
不可重入锁
若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞。
自旋锁
一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待。
然后不断判断锁是否能够被成功获取,直到获取到锁才会退出循环。
任何时刻最多只有一个执行单元获取到锁
不会发生线程状态的切换,一直处于用户态;减少了线程的上下文切换,但同时也消耗了CPU
其他
- 共享锁
这种锁添加后,多线程可以读,但无法写。
该锁可被多个线程持有,用于资源数据共享
- 互斥锁
该锁只能被一个线程持有。
加锁后其他线程试图获取此锁,都会阻塞,知道当前线程解锁
- 死锁
两个或两个以上的线程在执行过程中,由于资源竞争,导致阻塞
- 偏向锁–>轻量级锁–>重量级锁
这三种锁,是JVM为了提高锁的获取和释放效率而做的优化。
针对synchronized的锁升级。锁的状态通过对象监视器在对象头中的字段来表名,是不可逆的过程。
- 分段锁/行锁/表锁
死锁案例与解决
写一个死锁,并解决
- 死锁的引发
import java.util.concurrent.TimeUnit;
public class DeadLockDemo {
//这两个对象就代表锁
private static String locka = "locala";
private static String lockb = "localb";
public void methodA() {
//以同步代码块的方式使用锁
synchronized (locka) {
System.out.println("我是A方法,获取锁A" + Thread.currentThread().getName());
try {
// 让出CPU执行权,不释放锁
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockb) {
System.out.println("我是A方法,获取锁B" + Thread.currentThread().getName());
}
}
}
public void methodB() {
synchronized (lockb) {
System.out.println("我是B方法,获取锁B" + Thread.currentThread().getName());
try {
// 让出CPU执行权,不释放锁
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locka) {
System.out.println("我是B方法,获取锁A" + Thread.currentThread().getName());
}
}
}
public static void main(String[] args) {
DeadLockDemo deadLockDemo = new DeadLockDemo();
new Thread(() -> {
deadLockDemo.methodA();
}).start();
new Thread(() -> {
deadLockDemo.methodB();
}).start();
System.out.println("我是主线程:" + Thread.currentThread().getName());
}
}
1、线程1拿到锁a
2、线程1睡眠
3、线程2拿到锁b
4、线程2睡眠
5、线程1要拿锁b,线程2要拿锁a,但两把锁都未释放,就堵死了
以上,我们总结发生死锁的4个必要条件
1、互斥条件:资源不能共享, 只能由一个线程使用
2、请求与保持条件:线程已经获得一些资源,但因请求其他资源发生阻塞,对已经获得的资源保持不释放
3、不可抢占:有些资源不可抢占,当某个线程获得资源后,系统不能强行回收,只能由线程自己用完释放
4、循环等待条件:多个线程下形成环形链,每个都占用了对方申请的下个资源
以上条件,只要有一个不成立,就不会发生死锁
- 解决死锁问题
常见方法
1、调整申请锁的范围
对于上述的案例中,只需要将锁的范围调整为
public void methodA() {
synchronized (locka) {
System.out.println("我是A方法,获取锁A" + Thread.currentThread().getName());
try {
// 让出CPU执行权,不释放锁
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized (lockb) {
System.out.println("我是A方法,获取锁B" + Thread.currentThread().getName());
}
}
就不会发生死锁
锁的范围越小越好。
如无必要,尽量不要让代码在锁中
调整申请锁的顺序
不可重入锁的设计
- 什么是不可重入锁?
当前线程执行某个方法的时候获取到了锁A,在锁A释放前,如果在方法中再次尝试获取锁A,此时会获取不到被阻塞。
这就是不可重入锁
package com.zhangln.shanhaijing.thread;
/**
* 不可重入锁
*
* @author sherry
* @description
* @date Create in 2020/8/10
* @modified By:
*/
public class UnreentrantLock {
private boolean isLocked = false;
public synchronized void lock() throws InterruptedException {
System.out.println("进入lock加锁" + Thread.currentThread().getName());
while (isLocked) {
System.out.println("进入wait等待:" + Thread.currentThread().getName());
wait();
}
isLocked = true;
}
public synchronized void unlock() {
System.out.println("进入unlock解锁" + Thread.currentThread().getName());
isLocked = false;
// 唤醒当前对象锁等待队列中的某个线程
notify();
}
}
- 使用
private UnreentrantLock unreentrantLock = new UnreentrantLock();
@Test
public void test3() {
try {
unreentrantLock.lock();
System.out.println("1 获取锁");
unreentrantLock.lock();
System.out.println("2 获取锁");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
unreentrantLock.unlock();
}
}
可重入锁设计
- 什么是可重入锁
在外层获取到锁后,内层可以继续获取锁
package com.zhangln.shanhaijing.thread;
/**
* @author sherry
* @description
* @date Create in 2020/8/12
* @modified By:
*/
public class ReentrantLockDemo {
private boolean isLocked = false;
// 用于记录是不是重入的线程
private Thread lockOwner = null;
// 累计加锁次数
private int lockedCount = 0;
public synchronized void lock() throws InterruptedException {
System.out.println("进入lock加锁" + Thread.currentThread().getName());
Thread thread = Thread.currentThread();
// 判断是否是同一个线程获取锁
while (isLocked && lockOwner != thread) {
System.out.println("进入wait等待:" + Thread.currentThread().getName());
System.out.println("当前锁状态:" + isLocked);
System.out.println("当前加锁次数:" + lockedCount);
wait();
}
isLocked = true;
lockOwner = thread;
lockedCount++;
}
public synchronized void unlock() {
System.out.println("进入unlock解锁" + Thread.currentThread().getName());
Thread thread = Thread.currentThread();
// 自己加的锁只有自己才能解
if (thread == lockOwner) {
lockedCount--;
if (lockedCount == 0) {
lockOwner = null;
isLocked = false;
// 唤醒当前对象锁等待队列中的某个线程
notify();
}
}
}
}
- 测试调用
@Test
public void test5() {
try {
reentrantLockDemo.lock();
reentrantLockDemo.lock();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
reentrantLockDemo.unlock();
}
}
Synchronized深入理解
- 基本理解
- jdk6以后的优化
CAS
- 概念
Compare And Swap,即 比较再交换
是实现并发技术的一种
底层通过Unsafe类实现原子性操作,操作包含三个数:内存地址(V)、预期原值(A)和新值(B)。
如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值,如果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能执行。
整个CAS的过程,就是一个乐观锁的过程
AtomicXXX等原子类,底层就是CAS实现的。一定程度上比synchronized性能好,因为synchronized是悲观锁。
注意:线程多的时候,由于CAS自旋,性能反而不好
- ABA问题
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KGUgU1DC-1597558446071)(http://tuchuang.zhangln.com/s31CQC.png)]
AQS
- 基本概念
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9eLYLOV8-1597558446072)(http://tuchuang.zhangln.com/nyfooX.png)]
AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks 包下面。它是一个Java提高的底层同步工具类,比如CountDownLatch、ReentrantLock, Semaphore,ReentrantReadWriteLock,SynchronousQueue,FutureTask等皆是基于 AQS的
只要搞懂了了AQS,那么J.U.C中绝大部分的api都能轻松掌握
简单来说:是用一个int类型的变量表示同步状态,并提供了一系列的CAS操作来管理这个同步状态 对象
一个是 state(⽤于计数器,类似gc的回收计数器)
一个是线程标记(当前线程是谁加锁的)
一个是阻塞队列(用于存放其他未拿到锁的线程)
解析ReentranLock
- 通过参数控制ReentranLock是公平锁还是非公平锁,默认是非公平锁
- 内部重写了AQS,自定义了锁
ReentranLock与synchronized差别
- 两者都是独占锁
- synchronized是悲观锁,会引起其他线程阻塞
- synchronized无法判断锁状态,可重入、不可中断,非公平锁
- 加锁过程是隐式的
- 一般的并发场景足够用了
- ReentranLock是悲观锁,实现了Lock接口
- 可判断是否获取到锁,可重入,通过参数控制是否公平锁
- 需要手动加锁/解锁,解锁操作尽量放在finaly代码块中,保证线程正确释放锁
- 在复杂并发场景下,确保获取锁的次数与释放锁的次数一致,否则会导致其他线程无法获取锁
读写锁ReentrantReadWriteLock
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-l2TZPBFC-1597558446073)(http://tuchuang.zhangln.com/20200816133132_uaWsk3_325C598A-64DA-421A-A6A9-B940D36F6D13.png)]
- ReentrantReadWriteLock实现了读写分离锁,适合并发读多的场景
- 支持公平锁与非公平锁,底层也是AQS的实现
- 允许从写锁降级为读锁
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cRwSFBJQ-1597558446075)(http://tuchuang.zhangln.com/20200816133614_nbdSmU_0B969139-E2EC-471F-9D18-286621C5D54F.png)]
BlockingQueue
核心:保证生产者不在缓冲区满的时候放入数据,消费者不在缓冲区空时消耗数据
常见的同步方法是采用信号量或加锁机制
并发最佳实践
- 不同模块取不同的线程名称,便于后续问题排查
- 使用同步代码块或同步方法时,尽量减小同步范围
- 多比那个发集合少使用同步集合
- 线程业务需要使用多线程时,优先考虑线程池