本文是学习Java多线程与高并发知识时做的笔记。
这部分内容比较多,按照内容分为5个部分:
- 多线程基础篇
- JUC篇
- 同步容器和并发容器篇
- 线程池篇
- MQ篇
本篇为多线程基础篇。
目录
1 什么是线程?
线程是操作系统进行运算调度的基本单位。
计算机的一切信息处理都是由CPU来最终完成的,每个(单核)CPU只能同时处理一个线程的请求。
操作系统是CPU的经纪人,操作系统对所有的应用程序说:“我们的CPU只能同时处理一个线程的请求,你们把你们的进程以线程为单位拆分好再过来。”
等应用程序把自己的进程拆分成一个一个线程后,操作系统提供一些内核线程和应用程序的线程一一对接,然后请CPU来处理这些内核线程的请求。
如图:
一个运行中的应用程序对应一个进程,一个进程对应多个线程。
2 线程的状态
一般来讲,线程共有5种状态:
- 新建状态(New):线程对象被创建后,进入新建状态。
- 就绪状态(Runnable):新建线程对象启动后,进入就绪状态。就绪状态的线程随时可能被CPU调度执行。
- 运行状态(Running):线程被CPU调度执行。需要注意的是,线程只能从就绪状态进入运行状态。
- 阻塞状态(Blocked):线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会继续运行。
- 死亡状态(Dead):线程执行完成或因异常退出时,该线程结束生命周期。
在Java实现线程时,将线程分为6种状态:
- NEW(新建):线程对象被创建后,进入新建状态。
- RUNNABLE(运行):Java中将线程的就绪状态和运行状态统一为RUNNABLE状态。
- BLOCKED(阻塞):线程暂时停止运行,等待获得锁资源。
- WAITING(等待):等待其它线程做出特定动作(通知或中断)。
- TIMED_WAITING(超时等待):等待其它线程做出特定动作(通知或中断),或者在指定时间后转为就绪状态。
- TERMINATED(终止):线程执行完毕。
3 线程的创建
在Java中,创建线程有两种最基本的方式:
- 继承Thread类
- 实现Runnable接口
public class CreateThread{
static class MyThread extends Thread{ //继承Thread类
@Override
public void run(){ //重写run()方法
System.out.println("Hello MyThread!");
}
}
static class MyRunnable implements Runnable{ //实现Runnable接口
@Override
public void run(){ //重写run()方法
System.out.println("Hello MyRunnable!");
}
}
public static void main(String[] args){
new MyThread().start(); //调用start()方法启动线程
new Thread(new MyRunnable()).start(); //调用start()方法启动线程
}
}
在java.util.concurrent中提供了一个Callable接口,实现Callable接口创建线程可以有一个返回值:
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class CreateThread {
static class MyCallable implements Callable<Integer> { //泛型规定返回值类型
@Override
public Integer call() throws Exception { //重写call()方法,类似于Runnable接口中的run()方法
System.out.println("Hello MyCallable");
return 1024;
}
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable myCallable = new MyCallable();
FutureTask futureTask = new FutureTask(myCallable); //适配器模式
new Thread(futureTask).start(); //调用start()方法启动线程
//打印返回值
Integer result = (Integer) futureTask.get();
System.out.println(result);
}
}
另外,使用Lambda表达式也可以创建线程。
public class CreateThread{
public static void main(String[] args){
new Thread(()->{
System.out.println("Hello Lambda!");
}).start();
}
}
Thread类中的常用方法:
sleep():休眠,使线程进入阻塞状态,给其它线程让出执行机会,等休眠时间结束后,线程进入就绪状态和其它线程竞争CPU资源。
yield():礼让,使线程进入就绪状态,和其它线程竞争CPU资源。
join():合并,当前线程阻塞,先将join的线程执行完,再继续执行当前线程。可以用来保证线程之间的顺序执行。
4 线程同步
线程同步:当有一个线程在对内存进行操作时,其它线程不可以对这个内存地址进行操作,直到该线程操作完成。该线程操作完成前,其它线程处于等待状态。
Java中使用synchronized关键字来实现线程同步。
synchronized关键字的作用是给对象上锁,一把锁在任一时刻只能被一个线程持有。
提问:synchronized是给对象上锁还是给代码上锁?
答:给对象上锁。例如在下面的代码段中,正确的表达是,synchronized给o上锁,线程在拿到o之后可以执行大括号{ }内的代码。
public class Test{ private int count = 10; private Object o = new Object(); public void test(){ synchronized(o){ count--; System.out.println(Thread.currentThread().getName() + " count = " + count); } } }
因为每次都要创建新的Object对象比较麻烦,所以上面的代码段可以改为:
public class Test{ private int count = 10; public synchronized void test(){ count--; System.out.println(Thread.currentThread().getName() + " count = " + count); } }
在上面的程序中,synchronized上锁的对象是this。
如果synchronized修饰的方法是静态方法(static),那么上锁的对象就是Test.class。
5 synchronized
5.1 Java对象的结构
在讲synchronized的底层实现之前,需要了解Java对象的结构。
一个Java对象在堆中占用16个字节(除了数组对象),其中:
前8个字节叫markword,markword里面存着锁信息、hashcode、GC信息等;
在Hotspot(一种常用的JVM)中,对象的markword中记录的信息如图所示:
第9到12个字节叫klasspointer,它是一个指针,指向这个对象对应的类(class);
第13到16个字节存的可能是instancedata,实例数据(成员变量),如果这个对象没有实例数据,存的就是padding,对齐,它的作用是将对象长度补到能被8字节整除。
(如果是数组对象,在classpointer和instancedata之间多了一个arraylength,数组长度)
回过头来看synchronized给对象上锁这个概念,上锁的本质就是在对象的markword里记录锁信息。
那么记录下来的锁信息是什么呢?
指向当前线程的指针。
5.2 synchronized的锁升级过程
最早的时候,synchronized锁的效率很低(因为只使用重量级锁),后来的jdk版本对其进行了优化,现在的synchronized锁包括四种状态:new、偏向锁、轻量级锁(也叫自旋锁)、重量级锁。
越强力的锁,对系统资源的消耗越多,所以能用低一级锁解决的问题,尽量用低一级的锁解决。低一级锁解决不了问题时,锁升级为更高级的锁。
锁的升级过程为:
新创建对象(new)-->偏向锁-->轻量级锁(自旋锁)-->重量级锁
(这里有个很讨厌的概念:无锁,指的是非重量级锁,理解就可以,不建议使用这个概念)
实际上锁的升级过程要更复杂一些,如图所示:
5.2.1 偏向锁
偏向锁认为,在大部分情况下,synchronized上锁的对象只有一个线程要使用。
给新创建对象上偏向锁:在对象的markword里记录当前线程的指针JavaThread*。
只要有其它线程来抢这个对象(轻度竞争),偏向锁就升级为轻量级锁。
偏向锁机制默认在JVM运行4秒后启动,延迟启动的原因是:在启动JVM时一定会存在多个线程争抢对象的情况。
偏向锁机制启动后,所有的新创建对象都默认上偏向锁——markword中的偏向锁位 置1。当对象的偏向锁位为1而markword里没有记录任何线程的指针时的状态叫作匿名偏向,当有线程来取这个对象的时候,记录线程的指针,匿名偏向锁升级为偏向锁。
5.2.2 轻量级锁(自旋锁)
(轻度竞争下)偏向锁升级为轻量级锁:首先撤销对象的偏向锁状态,然后每个线程在自己的线程栈里生成LR(Lock Record,锁记录),并尝试向对象的markword里写入指向自己LR的指针。哪个线程写入了指针,哪个线程就抢到了对象的轻量级锁。
轻量级锁是通过CAS的方式实现的。
CAS:Compare And Swap,比较和交换。
如图所示,系统使用数据E做完一次运算后,在回写结果时比较当前的E值(图中的N)和运算前的E值是否相等,如果相等则写入运算结果;如果不相等,则取当前的E值重新进行运算,循环此过程。
关于CAS有两个经典问题:
(1) ABA问题:在运算过程中,其它线程对E值进行了多次修改,但最终E值仍与运算前相等,造成“此A非彼A”的问题。如何解决这个问题?
加版本号。
(2) 操作原子性问题:操作“比较E值是否与运算前一致”和操作“回写计算结果”,两个操作的原子性是怎么保证的?
CAS的最底层是一句汇编语言:lock cmpxchg
lock指令:锁总线,CPU在执行指令时,不会被其它CPU打断。
CAS本质上是不断循环的程序,它会占用CPU资源,所以当线程竞争激烈时,轻量级锁升级为重量级锁。
5.2.3 重量级锁
重量级锁将激烈竞争的多个线程放入等待队列,由操作系统来负责线程调度。
放入等待队列的线程不占用CPU资源。
轻量级锁在什么情况下升级为重量级锁?
在jdk1.6以前,如果有线程自旋超过10次,或自旋线程数目超过CPU核数的二分之一时,锁升级。
jdk1.6以后,默认启动自适应自旋,由JVM自己来控制是否升级。
6 volatile
volatile是一个特征修饰符,作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值。
比如下面的程序:
XBYTE[2]=55;
XBYTE[2]=56;
XBYTE[2]=57;
XBYTE[2]=58;
对外部硬件而言,上述语句表示对XBYTE[2]进行了四次赋值,但是编译器却会对上述语句进行优化,认为只有XBYTE[2]=58是有效的(即忽略前三条语句,只产生一条机器代码)。如果键入volatile,则编译器会逐一进行编译并产生相应的机器代码(产生四条机器代码)。
volatile有两个作用:保证线程可见性、阻止指令重排序。
6.1 volatile保证线程可见性
一个线程对主存的修改能够及时地被其它线程观察到,这种特性被称之为可见性。
接下来会要讨论两个问题:
- 为什么要保证线程可见性?
- volatile是怎么保证线程可见性的?
6.1.1 为什么要保证线程可见性?
首先我们要知道,CPU并不是直接从主存里读数据的,之间要经过缓存。
缓存(即高速缓存,Cache)位于CPU与主存之间,分为L1、L2、L3三级。CPU从主存读数据的时候,会首先到L1里面找需要的数据,L1里面如果没有去L2里面找,L2里面没有去L3里面找,L3里面没有的话,从主存读进来。反过来,数据从内存首先被读入L3,然后被读入L2,然后被读入L1。
缓存从主存读数据的时候(遵循程序局部性原理)会按块读取,每块数据叫一个缓存行(cache line),缓存行的大小一般为64字节。因此主存中的一行数据很可能会被多个CPU的缓存同时读取,在某个CPU对这行数据进行修改后,尽管修改后的数据被写入主存,其它CPU仍然从自己的缓存中读到修改前的数据,这是不应该出现的情况。
同一行数据被读入不同CPU的时候,需要保证各个CPU中数据一致。
6.1.2 volatile是怎么保证线程可见性的?
volatile保证线程可见性的实现方式是:保证缓存行之间的数据一致性。
CPU实现缓存行之间数据一致性的方式是:遵守MESI 缓存一致性协议,如果还是不行就锁总线。
MESI 缓存一致性协议是Intel底层协议,Modified修改、Exclusive独占、Shared共享、Invalid失效。
锁总线通过汇编指令lock。
6.2 volatile禁止指令重排序
CPU乱序执行(指令重排序):
CPU在需要执行两条指令的时候,第一条指令执行比较慢,第二条执行比较快,在两条指令不相关的情况下,有可能先执行第二条指令,再执行第一条指令。
CPU乱序执行在单线程下不会产生问题,但在多线程下可能会产生问题。
volatile禁止CPU进行指令重排序。实现的方式是加内存屏障,内存屏障前后的指令不允许重排序。
JVM要求实现四种内存屏障:
loadload:读指令和读指令之间的屏障,屏障上方的读指令全部完成之后才能执行屏障下方的读指令;
storestore:写指令和写指令之间的屏障,屏障上方的写指令全部完成之后才能执行屏障下方的写指令;
loadstore:读指令和写指令之间的屏障,屏障上方的读指令全部完成之后才能执行屏障下方的写指令;
storeload:写指令和读指令之间的屏障,屏障上方的写指令全部完成之后才能执行屏障下方的读指令。
这四种内存屏障在底层是通过汇编指令实现的。
volatile操作前后的内存屏障:
7 线程间通信
7.1 生产者和消费者问题
生产者和消费者问题:
由多个线程同时操作同一个变量number:
- 生产者每次操作,number++
- 消费者每次操作,number--
当number == 0时,消费者不能对其进行操作。
代码实现:
public class Test {
public static void main(String[] args) {
Data data = new Data();
int n = 4; //消费者数目
int k = 5; //每个消费者的消费额
new Thread(() -> {
for (int i = 0; i < n * k; i++) {
try {
data.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "Producer").start(); //生产者
for (int id = 0; id < n; id++) {
new Thread(() -> {
for (int i = 0; i < k; i++) {
try {
data.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "Consumer" + id).start(); //消费者们
}
}
}
class Data {
private int number = 0;
//synchronized方法
public synchronized void increment() throws InterruptedException {
while (number > 3) { //while轮询
this.wait(); //线程等待
}
number++;
System.out.println(Thread.currentThread().getName() + "=>" + number);
this.notifyAll(); //通知其它线程
}
//synchronized方法
public synchronized void decrement() throws InterruptedException {
while (number <= 0) { //while轮询
this.wait(); //线程等待
}
number--;
System.out.println(Thread.currentThread().getName() + "=>" + number);
this.notifyAll(); //通知其它线程
}
}
生产者和消费者问题中的三个关键点:
- synchronized
- wait和notify
- while轮询
关于synchronized不再赘述。
7.2 wait和notify
wait():使当前线程等待,可使用notify()或notifyAll()方法唤醒。
wait()和sleep()的区别:
- wait()是Object类中的方法,而sleep()是Thread类中的方法
- 调用wait()会释放锁(进入等待状态),而调用sleep()不会释放锁(进入阻塞状态)
- wait()只能在同步代码块中使用,而sleep()可以在任何地方使用
notifyAll():唤醒所有等待的线程。
notify():唤醒一个等待的线程。
在JDK源码的注释中说道notify()选择唤醒的线程是任意的,但是依赖于具体实现的JVM。
hotspot对notify()的实现是顺序唤醒,即“先进先出”。
7.3 while轮询
wait()方法总是出现在循环中,这是为了防止多线程下的虚假唤醒问题。
在生产者-消费者模型中,可能在某个时刻产品数目为0,多个消费者线程等待。这时生产者生产了一件产品,所有等待的消费者线程被唤醒,但是最终只有一个消费者线程能够获得产品,它被唤醒是有效果的;其它消费者线程只能继续等待,它们被唤醒是无效的,即虚假唤醒。
学习视频链接:
https://www.bilibili.com/video/BV1xK4y1C7aT
加油!(ง •_•)ง