前情提要:本文内容过多,建议搭配目录食用,想看哪里点哪里~
PC端目录
手机端目录
话不多说,我们正式开始~~
目录
- 多线程的概念
- 进程和线程的区别和联系:
- 使用Java代码进行多线程编程
- Thread类中的方法和属性
- 线程的核心操作
- 1. 启动线程start
- 2. 线程的终止
- 3. 线程等待
- 线程的状态
- 线程安全
- 锁
- 死锁问题与解决办法
- volatile关键字
- wait、notify
- 多线程代码案例
- 1. 单例模式
- 2. 阻塞队列
- 3. 线程池
- 4. 定时器
- 常见的锁策略
- 乐观锁与悲观锁
- 重量级锁与轻量级锁
- 挂起等待锁与自旋锁
- 公平锁与非公平锁
- 可重入锁与不可重入锁
- 读写锁
- synchronized的原理
- 1. 锁升级
- 2. 锁消除
- 3.锁粗化
- CAS
- JUC(java.util.concurrent)常见类
- Callable接口
- ReentrantLock
- 信号量Semaphore
- CountDownLatch
- 线程安全的集合类
多线程的概念
CPU是多核心的CPU
以前写的代码,都只能够使用一个核心,不管如何优化代码,都只能使用一个核心,其他核心是空闲着的
并发编程:通过编写特殊的代码,把多个CPU核心都能够利用起来,这样的代码就是"“并发编程”
多进程编程就是一种并发编程
多进程的问题:创建进程、销毁进程,开销比较大(时间、空间)
为了解决这个问题,于是就有了线程
线程(Thread): 是更加轻量的进程,也能解决并发编程问题,开销比进程更低
现在主流的并发编程就是多线程
进程和线程的区别和联系:
一个进程包含一个或者多个线程(每个线程都会有自己的状态、优先级、上下文、记账信息,每个都会各自独立在CPU上调度执行)
进程: 进程(Process)是操作系统中资源分配的基本单位,它是一个正在运行的程序的实例。进程包括程序代码以及其当前活动,包括程序计数器、寄存器内容、变量、程序的堆和栈等。操作系统通过进程来管理和调度程序的执行,每个进程拥有独立的资源和内存空间。
线程: 线程(Thread)是操作系统中执行调度的基本单位,一个进程可以包含一个或多个线程,这些线程共享进程的资源(内存、硬盘、网络带宽),但每个线程都有自己的程序计数器、栈和局部变量。
并发:指的是多个任务在同一时间段内交替执行,实际的执行可能是串行的。即使只有一个CPU核心,也可以按照分时复用来切换多个线程,宏观上看像是同时执行的
并行:指的是多个任务在同一时间内同时执行,需要多个CPU核心支持。在多核CPU上,多个进程或线程可以真正同时运行。
并发与并行:
多个核心,每个核心都可以同时执行一个线程,这些核心之间的执行过程是“同时执行的”,这就叫并行;
一个核心,也可以按照分时复用。来切换多个线程,微观上看,线程是一个接一个执行的,但是调度速度快,所以宏观上看像是同时执行的这就叫并发
总结:
-
一个进程包含一个或者多个线程(不能没有线程)
-
进程是系统资源分配的基本单位,线程是系统调度执行的基本单位
-
同一个进程里面的线程之间,共用同一份系统资源(内存、硬盘、网络带宽)
-
多个线程之间,可能会相互影响(线程安全问题),多个进程之间,一般不会相互影响(进程的隔离性)
-
线程数目不是越多越好,当线程数目达到一定数量,把多个核心都充分利用了之后,此时再增加线程也是无法提高效率的
举个例子:当我们双击QQ,启动QQ程序时,操作系统会创建一个新的进程,并且分配系统资源来运行QQ,在QQ程序中,可以创建多个线程来处理不同的任务:发送视频,语音通话、视频通话等这些功能都共用创建进程时分配的资源
使用Java代码进行多线程编程
线程是操作系统提供的概念,不同的系统会提供不同的API供程序员使用,而JVM将这些API封装好了(封装成了Thread类),Java程序员只需要熟悉Thread这个类就好了
创建线程第一种方法:自己创建一个类,这个类继承Thread,并且重写run方法
//Thread可以直接使用,不需要导包,已经默认导入了(java.lang)
class MyThread extends Thread {
//重写Thread里面的run方法
@Override
public void run() {
//这里写的代码即将创建出的线程,要执行的任务逻辑
while (true) {
System.out.println("Hello Thread");
try {
Thread.sleep(1000);//方便观察,每打印一次就休息一会
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class demo1 {
public static void main(String[] args) throws InterruptedException {
MyThread t = new MyThread();
t.start();//调用start,就会在进程内部,创建一个新的线程,新的线程就会执行我们刚才重写的run里面的代码
while (true) {
System.out.println("Hello Main");
Thread.sleep(1000);
}
}
}
上述代码其实是包含两个线程的,一个是我们手动创建的线程,另一个是调用main方法的主线程(一个线程至少会有一个线程,而这个线程就是主线程)
创建线程第二种方法:自己创建一个类,这个类实现了Runnable接口,并且重写run方法
class MyRunnable implements Runnable {
@Override
public void run() {
//这里写的代码即将创建出的线程,要执行的任务逻辑
while (true) {
System.out.println("Hello Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class demo2 {
public static void main(String[] args) throws InterruptedException {
MyRunnable myRunnable = new MyRunnable();
Thread t = new Thread(myRunnable);
t.start();
while (true) {
System.out.println("Hello Main");
Thread.sleep(1000);
}
}
}
创建线程第三种方法:定义匿名内部类,类的内部重写了run方法
public class demo3 {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread() {
@Override
public void run() {
while (true) {
System.out.println("Hello Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
t.start();
while (true) {
System.out.println("Hello Main");
Thread.sleep(1000);
}
}
}
创建线程第四种方法:
public class demo4 {
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
while (true) {
System.out.println("Hello Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread t = new Thread(runnable);
t.start();
while (true) {
System.out.println("Hello main");
Thread.sleep(1000);
}
}
}
创建线程第五种方法:基于lambda表达式
Thread t = new Thread(() -> {
//代码逻辑
});
大括号中写我们创建新的线程要执行的任务逻辑
例如
public class demo5 {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (true) {
System.out.println("Hello Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
while (true) {
System.out.println("Hello main");
Thread.sleep(1000);
}
}
}
查看线程信息的方法:
找到自己安装的jdk目录,点开bin目录
找到jconsole.exe这个文件,双击打开,选择你的进程(得让程序运行起来,你才能找到你要查看的进程内容)
点击连接
点击不安全的连接
查看线程
Thread类中的方法和属性
方法
方法 | 说明 |
---|---|
Thread() | 创建线程对象,无参数 |
Thread(Runnable target) | 使用Runnable对象创建线程对象 |
Thread(String name) | 创建线程对象并给线程命名(不起名字默认是Thread-数字) |
Thread(Runnable target,String name) | 使用Runnable对象创建线程对象,并给线程命名 |
属性
属性 | 获取该属性的方法 | 说明 |
---|---|---|
ID | getID() | Thread对象的身份标识 |
名称 | getName() | 线程的名称 |
状态 | getState() | 线程所处的状态 |
优先级 | getPriority() | 线程的优先级,会影响线程的调度 |
是否为后台线程 | isDaemon() | |
是否存活 | isAlive() | |
是否被中断 | isInterrupted() |
关于后台线程、前台线程
如果某个线程在执行过程中,能够阻止进程结束(后台线程结束了,也不会影响进程的结束),这样的线程就是前台线程;如果某个线程在执行过程中不能阻止进程结束(虽然线程进行着,但是进程一旦结束,这个线程也跟着结束),这样的线程就是后台线程。
注意:一个进程中可以有多个前台线程,我们自己创建的线程默认就是前台线程,当所有的前台线程都结束时,进程才会结束。另外主线程(main)也是前台线程
public class demo6 {
public static void main(String[] args) {
Thread t = new Thread(()->{
while (true) {
System.out.println("Hello Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.setDaemon(true);//把t设为后台线程,如果参数为false表示把线程设为前台线程
t.start();
System.out.println(t.isDaemon());
}
}
运行结果如下:
isAlive 是否存活
在代码中,创建的Thread对象生命周期和内核中实际的线程是不一样的,可能出现:Thread对象存在,但是内核中的线程不存在
,例如:1、Thread对象创建好了,但是在调用start之前内核中还没创建线程2、线程中的run执行完毕,内核中的线程已经销毁,但是Thread对象仍然存在
使用isAlive可以判断线程是否存活
public class demo7 {
public static void main(String[] args) {
Thread t = new Thread(() -> {
while (true) {
System.out.println("Hello Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
System.out.println(t.isAlive());
t.start();
}
}
线程的核心操作
1. 启动线程start
start和run的区别是什么?run描述了线程要执行的任务,也可以称为线程的入口,而start则会根据不同的系统,间接调用了系统函数(api),在系统内核中创建线程,创建好的线程再来单独执行run。一旦start执行完毕,新的线程就会开始执行。所以start的本质其实是调用系统的api~
另外,start不一定非得在主线程中调用,也就是说,在其他线程中也能创建新的线程,例如
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
//在t1线程中创建t2线程
Thread t2 = new Thread(() -> {
});
t2.start();
});
t1.start();
}
注意事项:一个Thread对象只能调用一次start,如果多次调用会抛出“非法的线程状态异常”,在Java中,一个Thread对象对应系统中的一个线程,如果Thread对象还没调用过start方法,此时线程状态为new状态,如果调用过start方法,就会进入其他状态,只要不是new状态,接下来的start调用都会抛出异常
2. 线程的终止
线程的终止就是结束一个线程,想要让线程结束,核心是让该线程的run方法更快执行完毕,如何终止?
Thread中有一个属性isInterrupted(),初始状态值为false(未被终止),一旦其他线程调用了Interrupt方法,通过调用Interrupt来设置标志位,从而达到终止线程的目的
报错原因:判定isInterrupted和打印操作速度快,因此整个循环大部分时间都在sleep,main调用Interrupt的时候,t大概率在sleep
此时Interrupt可以把sleep给唤醒(通过抛出异常的方式来唤醒),sleep等阻塞的函数一旦被唤醒,就会清空设置的Interrupt标志位
public class demo10 {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
Thread currentThread = Thread.currentThread();//获取到线程的引用
while (!currentThread.isInterrupted()) {//是否被终止
System.out.println("Hello Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("执行到catch语句");
break;
}
}
System.out.println("t 线程结束");
});
t.start();
Thread.sleep(3000);
t.interrupt();//在main线程中,终止t线程
}
}
A希望B线程终止:
1、如果B线程想无视A,直接在catch语句中啥也不做
2、如果B线程想立即结束,直接在catch语句中添加return或者break
3、如果B想稍后结束,则可以在catch语句中写上其他逻辑
结论:
Interrupt方法能够设置标志位,也能唤醒sleep等阻塞方法,sleep被唤醒后又能情况标志位
3. 线程等待
操作系统针对多个线程的执行,是随机调度、抢占式执行的,线程的执行顺序是不确定的,但是我们可以通过线程等待来控制线程的结束顺序,也就是控制哪个线程后执行,哪个线程先执行。让后执结束的线程等待先结束的线程即可,此时后结束的线程会进入阻塞状态,一旦先结束的线程执行完毕,阻塞就解除。我们可以用join方法来实现上述过程,如果两个线程a、b,如果要让a后结束,也就是a线程等待b线程,我们可以在a线程中调用b.join方法
Thread b = new Thread(()->{});//b线程
Thread a = new Thread(()->{
b.join();
});//a线程
上述代码的效果就是:让a线程最结束,b线程先结束
也就是说,谁调用join谁就先结束
举个例子:
在主线程中调用join,效果是主线程等待a线程,让a线程先结束,调整代码
上述代码中在join之前加了sleep(4000),表示在主线程中调用join之前先休眠4s,当休眠结束,此时a线程早已执行完毕,也就是说,如果线程a结束了,主线程再开始等待,等待则不会生效。
上述的join都是无参数版本,表示死等,只要被等待的线程没有执行完毕,就一直等待。join还有一个带参数版本,参数表示等待的时间,单位毫秒,如果超过了等待时间,则不会继续等待。
Thread.sleep,当线程执行sleep时,会让这个线程不参与CPU调度,从而把CPU资源让出来。
线程的状态
在Java中,对线程的状态做了详细的区分,主要有6种状态:
1、NEW:当前Thread对象虽然创建了,但是在系统内核中还没有创建线程(没有调用start)
2、TERMINATED:当前Thread对象虽然还存在,但是内核中的线程早已销毁(线程已经结束了)
3、RUNNABLE:就绪状态,包括正在CPU上运行、随时可以去CPU上运行两种状态
4、BLOCKED:阻塞状态,由锁竞争引起的阻塞
5、TIMED_WAITING:阻塞状态,有超时时间的阻塞等待(如sleep、join带参数版本)
6、WAITING:阻塞状态,没有超时时间的阻塞等待
线程安全
多个线程同时执行某个代码的时候,可能会引起一些bug~
例如下面的bug
public class demo14 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
上述代码的逻辑很容易理解,创建两个线程分别对count进行自增,想要达到的效果就是count=100000,但是运行起来结果却不是100000,而且每次运行代码结果都不一样
但是我们稍微修改一下下代码
public class demo14 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t1.join();
t2.start();
t2.join();
System.out.println(count);
}
}
此时运行代码,就能得到我们想要的结果,并且每次运行都是这个结果
上述因多线程并发执行引起的bug,这样的bug称线程安全问题,也称线程不安全
分析产生bug的原因:
代码中的count++,对于CPU来说,是3个指令:
1、把内存中的数据读取到CPU寄存器中
2、把CPU寄存器的数据+1
3、把寄存器中的值写回内存中
简单来说就是读取、计算、保存3个操作
由于CPU是随机调度执行、抢占式执行的,所以当CPU在调度执行线程的时候,会随时把线程切换走,而指令是CPU执行的基本单位,如果发生了线程切换,至少会把当前的指令执行完才会调度走。count++是3个指令,可能会出现执行了某1个或者2个或者3个指令被调度走的情况。上述代码是两个线程同时对count进行自增操作,就会很容易出现bug。
下图是理想状态下,不出bug的情况
t1执行完3个指令后接着t2执行3个指令,由于随机调度的问题,也可能会出现其他情况
而且我们写的代码是循环50000次,咱也不知道多少次情况是理想状态
构成线程安全问题的几个原因:
1、线程在操作系统中是随机调度、抢占式执行的(根本原因)
2、多个线程同时修改一个变量
3、修改操作不是"原子"的(原子表示不可分割的最小单位,对于CPU来说,不可分割的最小单位是一条指令):CPU在进行调度切换线程的时候,一定会保证执行完一条完整的指令,一定是把执行完当前指令线程才会被调度走,不存在执行半个指令。
4、内存可见性问题
5、指令重排序问题
如何避免线程安全问题?想要避免线程安全问题,我们要结合线程安全问题产生的原因。第一个原因,是操作系统内核的问题,我们无法干涉;第二个原因:既然不能让多个线程同时修改一个变量,那我们可以考虑让变量不能修改但是这样的方法对Java来说不是非常普适,所以解决线程安全问题最主要的办法就是把非原子的修改变成原子(至于第四、五个原因,我们后面会介绍到),如何把非原子的修改变成原子的?答案是“加锁”
锁
在Java中提供了synchronized这样的关键字,来完成加锁的操作,通过加锁操作,可以让上述提到的一个线程在执行count++的过程中,其他的线程执行count++不能插队~~
举个例子:漫长又无聊的英语课结束后,滑稽老铁的尿终于憋不住了,在下课铃响起后他飞奔厕所,动作流畅的把门锁上,此时其他的滑稽老铁也想上厕所,但是因为第一个滑稽老铁已经对厕所门进行加锁操作了,于是其他的老铁不能破门而入,只能老老实实等第一个滑稽老铁上完
如何使用synchronized?
synchronized (锁对象) {
//要打包成原子的代码或者任意其他代码
}
上述的锁对象可以是任意类型的对象
我们结合代码来看
public class demo15 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
上述代码则解决了之前提到的线程安全问题,t1和t2都是对locker对象加锁
t1先加锁,加锁成功,于是t1继续执行{}里面的内容,t2后加锁,但是发现locker已经被别人加锁了,于是t2只能进行阻塞等待,当t1解锁之后,t2才能对locker加锁。上述操作有效的前提是两个线程都是加锁的,并且是对同一个对象进行加锁
锁对象的作用:用来区分两个线程或者多个线程是否针对同一个对象进行加锁,如果是针对同一个对象加锁,会出现阻塞(锁竞争、锁冲突),如果不是针对同一个对象加锁,此时不会出现阻塞,两个线程仍然是随机调度的。锁对象是啥无所谓(可以是Object类型的对象,也可以是Objec类型的子类对象,或者其他任意对象),只要是同一个对象即可
如果是更多线程对同一个锁对象加锁呢?
此时情况也是类似的,首先其中某一个线程先加上锁(哪个线程先拿到锁是不确定的),其他线程阻塞等待,第一个线程释放锁之后,剩下的线程接着拿到锁(剩下的线程谁先拿到锁也是不确定的)
注意事项:synchronized是JVM提供的功能,synchronized底层是在JVM中,通过c++代码实现的,而进一步的是依靠操作系统提供的api实现加锁,操作系统的api又是来自于CPU上的某些特殊指令来实现的
加锁方式:
原生的加锁api其实是两个函数,一个是lock();一个是unlock();在lock和unlock()之间则是我们要打包的代码。但是Java中通过synchronized关键字来进行加锁操作和原生的略有区别,在Java中,只要进了synchronized的大括号就加锁,只要出了大括号就解锁,避免了加锁之后忘记解锁而产生bug’的情况
除了上述的用法外,synchronized还可以修饰方法,例如
synchronized public void fuc() {
//...
}
此时锁对象是this,也就是谁调用的这个方法谁就是锁对象
等价于
public void fuc() {
synchronized(this){
//...
}
}
如果是静态方法,此时锁对象是类对象
synchronized的几种使用方式总结:
1、synchronized(){}圆括号内指定锁对象
2、synchronized修饰普通的方法,锁对象是this
3、synchronized修饰static方法,锁对象是类对象
死锁问题与解决办法
锁的使用过程中一种典型的bug就是死锁。
出现死锁的原因
原因1:一个线程,针对一把锁,连续加锁两次,这种情况,就可能会产生死锁问题,但是Java的synchronized做了特殊处理,引入了特殊机制,解决这种情况产生的死锁问题(可重入锁),可重入锁就是在锁中,会额外记录一下当前是哪个线程对这个锁加锁了,如果发现加锁的线程是当前锁的持有线程,此时并不会真正进行加锁,也不会产生阻塞,而是直接放行往下执行代码,我们的synchronized就是可重入锁~~
小知识:如果有多层嵌套加锁,怎么判断哪个}是释放锁的操作?引入一个计数器,计数器初始值为0,每次执行到{时计数器+1,每次执行到}时计数器-1,当某次计数器的值变成0了,就说明这个时候要释放锁了。
原因2:若有两个线程(线程1、线程2)和两把锁(锁A、锁B),初始情况,线程1对A加锁,线程2对B加锁,线程1不释放锁A的情况下再对B加上,同时线程2不释放B的情况下对A加锁,这种情况也会出现死锁(如图)
例如代码:
public class demo17 {
private static Object A = new Object();
private static Object B = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (A) {
System.out.println("线程1对A进行加锁成功");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B) {
System.out.println("线程1对B进行加锁");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (B) {
System.out.println("线程2对B进行加锁成功");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (A) {
System.out.println("线程2对B进行加锁");
}
}
});
t1.start();
t2.start();
}
}
运行结果可以看出,线程1并没有对B加锁成功,线程2也并没有对A加锁成功,两个线程处于阻塞状态,这种情况可重入锁无法解决这个问题
原因3:N个线程,M个锁,这和一个经典的哲学家就餐问题非常像,什么是哲学家就餐问题呢?我们举个例子
桌上有一碗面条和五根筷子,还有五个哲学家想要吃面条,如果他们想吃面条,必须得拿起两根筷子,当五个哲学家都拿起左手边的筷子后,此时他们再尝试拿起右手边的筷子,发现右手边的筷子已经被别的哲学家拿着了,当他们吃不到面条时,绝对不会放下手中的筷子,谁都吃不上面条,谁也不想放下手中已有的筷子,只能干等着,这样的情况就造成了死锁问题
原因1因为有synchronized的特殊机制,我们不用解决,那么原因2和3如何解决?
首先我们需要清楚死锁的4个必要条件
1、锁是互斥的(锁的基本特性),锁的基本特性我们无法改变,所以解决死锁问题,考虑这个肯定不行~
2、锁是不可被抢占的(锁的基本特性),例如线程1拿到了锁A,如果线程1没有释放A,其他线程不能把A抢过来,锁的基本特性我们无法改变,考虑这个也是不行滴~
3、请求和保持,线程1拿到锁A之后,不释放A的前提下去拿锁B,这种情况叫做请求和保持(代码结构),代码结构我们是可以修改的,我们如果避免写成请求和保持这样的代码,就能解决死锁问题,例如我们线程1如果先释放A再去拿B
4、循环等待,多个线程获取锁的过程存在循环等待(例如:线程1拥有锁A,线程2拥有锁B,此时线程1再去拿锁B,线程2再去拿锁A,线程1需要线程2对锁B进行解锁之后才能拿到锁B,而线程2又需要等线程1对A进行解锁之后才能拿到锁A,这样就构成循环等待了)(代码结构),避免循环等待产生死锁问题,我们可以采用这种方法:先给每个锁都编好号,1,2,3…然后约定所有的线程在加锁的时候都必须按顺序来加锁,比如必须先针对编号小的锁加锁,后针对编号大的锁加锁.拿上述的哲学家问题来举例
针对上面的代码,我们就可以稍作修改来解决死锁问题,约定先加锁A,再加锁B
public class demo17 {
private static Object A = new Object();
private static Object B = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (A) {
System.out.println("线程1对A进行加锁成功");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B) {
System.out.println("线程1对B进行加锁");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (A) {
System.out.println("线程2对A进行加锁成功");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B) {
System.out.println("线程2对B进行加锁");
}
}
});
t1.start();
t2.start();
}
}
运行结果
小知识:Java中的集合类,很多都是线程不安全的,如果多个线程同时修改同一个集合类里面的数据,可能会出现线程安全问题(线程安全的集合类有:Vector,Stack,HashTable,StringBuffer)
volatile关键字
关于内存可见性引起的线程安全问题:考虑一个场景,一个线程修改变量a,另一个线程读取变量a,例如下面的代码
public class demo18 {
private static int a = 0;
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
Thread t1 = new Thread(() -> {
while (a == 0) {
}
System.out.println("t1循环结束");
});
Thread t2 = new Thread(() -> {
System.out.println("输入一个整数");
a = scanner.nextInt();
});
t1.start();
t2.start();
}
}
预期的结果应该是:输入整数1,此时a的值为1,t1线程中的循环应该结束,但是运行结果:
循环并没有结束
这个问题产生的原因就是内存可见性问题,t1线程中有一个循环,每次循环都要判断a==0是否成立,而每次判断都要做两件事:1、从内存中读取数据到寄存器这个,2、通过指令比较寄存器中的值和0是否相等。从内存中读取数据到寄存器中这个操作非常慢,JVM发现执行这个操作开销比较大,而且每次结果都一样,所以JVM会对这个读取数据的操作进行优化,优化后,每次循环不会重新读取内存中的数据,而是读取寄存器中的数据,这时用户修改a的值后,内存中的a已经改变了,但是循环读取的是寄存器中的数据,所以JVM感觉不到a的值已经变了。内存中a的改变,对于线程1来说是不可见的,这样引起的bug就叫内存可见性问题。
怎么解决这个问题?可以使用volatile关键字,volatile关键字可以提示编译器,告诉他被volatile修饰的变量是易变的,此时编译器就不会进行上述的优化操作。
对上述代码进行小小的修改
private static volatile int a = 0;
结果就能正确运行啦
注意事项:volatile只能解决内存可见性问题,不能解决原子性问题
wait、notify
多个线程之间,需要控制线程之间执行某个逻辑的先后顺序,就可以让后执行的逻辑使用wait,先执行的线程完成某些逻辑之后通过notify可以唤醒对应的wait,另外,引入wait、notify也是为了解决线程饿死问题。线程饿死就是在多个线程的环境下,其中某一个拿到锁之后解锁,解锁之后又重新拿锁,导致其他线程拿不到这把锁,这个线程把其他线程给饿死。使用wait、notify来解决线程饿死问题:让1号线程拿到锁1之后进行判断该线程是否真的能够执行某些逻辑,如果能就正常执行,如果不能,那就让1号线程主动释放锁,并且调用wait来阻塞等待,等到线程1可以执行某些逻辑的时候,其他线程可以通过notify来唤醒线程1
wait中会进行释放锁的操作,所以,调用wait的前提是先加上了锁,并且wait还会让线程进行阻塞操作,释放锁和阻塞操作是同时完成的,wait内部帮我们完成了这两个操作。
public class demo19 {
private static Object locker = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (locker) {
System.out.println("t1 wait之前");
try {
locker.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t1 wait之后");
});
Thread t2 = new Thread(() -> {
System.out.println("t2 notify之前");
synchronized (locker) {
locker.notify();//wait和notify是同一个对象,才能生效,notify也是要加锁的~
}
System.out.println("t2 notify之后");
});
t1.start();
t2.start();
}
}
运行结果
此外,wait还有带参数版本,可以指定超时等待的时间,如果wait达到了最大时间还没有notify,那么则不会再等待了而是直接继续执行
wait和sleep的区别:
wait的目的是为了提前唤醒,sleep是固定时间的阻塞,不涉及到唤醒(Interrupt操作表示的是线程要终止了,而不是唤醒);wait必须要搭配synchronized使用,并且wait会先释放锁同时进行等待,sleep和锁没有关系。
notify的唤醒机制:如果有多个线程,并且多个线程都在同一个对象上wait,notify唤醒的顺序是如何的?答案是随机唤醒一个线程。如果想唤醒所有的线程,可以使用notifyAll()方法,但是一般建议通过执行多次的notify来一个一个唤醒线程。
如果没线程wait却执行notify,或者只有一个线程wait但是执行多次notify,此时程序不会出现任何问题
小练习:
3个线程分别打印A、B、C,要实现打印顺序为A、B、C
public class demo20 {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
System.out.println("A");
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker1) {
locker1.notify();
}
});
Thread t2 = new Thread(() -> {
synchronized (locker1) {
try {
locker1.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("B");
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locker2) {
locker2.notify();
}
});
Thread t3 = new Thread(() -> {
synchronized (locker2) {
try {
locker2.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("C");
});
t1.start();
t2.start();
t3.start();
//可能存在的问题:先执行到notify,后执行到wait,这个时候并没有起到作用,解决办法:notify之前sleep
}
}
多线程代码案例
1. 单例模式
单例模式是设计模式中的一种,设计模式是根据特定场景给出的特定方案,可以理解为“公式”,我们可以按照公式写代码~~
在开发中,我们希望有的类在一个进程中,只能有一个实例(对象),此时我们可以使用单例模式(单个实例)来限制某个类只能有唯一的实例。
举个例子:我们在JDBC中使用DataSource来描述数据库在哪,一般来说,一个程序中只有一个数据库,对应的MySQL服务器只有一个,此时DataSource这个类没必要创建多个实例,这种情况就可以使用单例模式。
在代码中怎么实现单例模式?在Java中有两种比较常用的写法,一个是饿汉模式,一个是懒汉模式。
饿汉模式: 饿,就是迫切,在类加载(程序启动)的时候就会创建出这个单例的实例
class Singleton {
private static Singleton instance = new Singleton();
public static Singleton getInstance() {
return instance;
}
//构造方法设为私有,防止其他的类创建实例
private Singleton() {
}
}
public class demo22 {
public static void main(String[] args) {
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();
}
}
上述代码中,instance使用static修饰,属于类的实例,因为单例模式只允许一个实例,为了防止其他类继续创建实例,我们将构造方法设为私有
**懒汉模式:**懒汉模式推迟了创建实例的时间,当使用的时候才会创建实例,而不是程序启动就创建。
class SingletonLazy {
private static SingletonLazy instance = null;
public static SingletonLazy getInstance() {
if (instance == null) {
instance = new SingletonLazy();
}
return instance;
}
private SingletonLazy() {
}
}
public class demo23 {
public static void main(String[] args) {
SingletonLazy s1 = SingletonLazy.getInstance();
SingletonLazy s2 = SingletonLazy.getInstance();
System.out.println(s1 == s2);
}
}
饿汉模式和懒汉模式的区别,举个例子:
有一个编辑器,要打开非常大的文本文档
饿汉模式:编辑器一启动就把所有的文本内容都读取到内存中
懒汉模式:编辑器启动后,先加载一小部分的数据,等用户翻页再加载其他数据
上述两种模式中,饿汉模式不存在线程安全问题,懒汉模式存在线程安全问题,为啥?
懒汉模式设计到了修改,而饿汉模式只是读取,在多线程环境下,多个线程同时修改一个变量会有线程安全问题~ 在 if 判断和 new 操作会出现线程切换,所以我们要通过锁把这两个操作打包成原子
修改之后:
class SingletonLazy {
private static SingletonLazy instance = null;
private static Object locker = new Object();
public static SingletonLazy getInstance() {
synchronized (locker) {
if (instance == null) {
instance = new SingletonLazy();
}
}
return instance;
}
private SingletonLazy() {
}
}
上述修改,虽然解决了线程安全问题,但是也会出现阻塞,如果不是第一次new 对象(instance不是null了),if里面的语句不会执行,这时没有线程安全问题,但是还是会触发加锁,产生阻塞。所以我们在加锁之前进行一次判断,如果没有线程安全问题就不加锁了
class SingletonLazy {
private static SingletonLazy instance = null;
private static Object locker = new Object();
public static SingletonLazy getInstance() {
if (instance == null) {//判断是否要加锁
synchronized (locker) {
if (instance == null) {//判断是否要创建对象
instance = new SingletonLazy();
}
}
}
return instance;
}
private SingletonLazy() {
}
}
但是,上述代码还是有问题,上述代码可能会因为指令重排序引起线程安全问题,instance = new SingletonLazy();执行这个代码,包含多个指令(粗略的分为:1、分配内存,2、把地址赋值给引用,3、调用构造方法初始化内存),编译器会对指令执行的顺序进行优化,执行的顺序可能是1、2、3也可能是1、3、2,那么又回到线程被调度走的问题了
怎么解决?给instance加上volatile,
class SingletonLazy {
private static volatile SingletonLazy instance = null;
private static Object locker = new Object();
public static SingletonLazy getInstance() {
if (instance == null) {
synchronized (locker) {
if (instance == null) {
instance = new SingletonLazy();
}
}
}
return instance;
}
private SingletonLazy() {
}
}
上述代码就是正确的代码了
2. 阻塞队列
阻塞队列是在普通的队列基础上,做了扩充,阻塞队列的特性如下
1、线程安全:阻塞队列是线程安全的,标准库中的队列默认是线程不安全
2、阻塞队列具有阻塞的特性,如果队列为空,出队操作就会出现阻塞,阻塞到其他线程往队列中添加元素;如果队列为满,入队操作也会出现阻塞,阻塞到其他线程从队列取出元素为止。
基于阻塞队列,可以实现“生产者消费者模型”
生产者消费者模型是啥?举个例子就明白了~
滑稽老铁一家三口准备包饺子,A老铁负责擀饺子皮,B老铁和C老铁负责包饺子,A老铁每次擀出一张饺子皮就把皮放到盘子中,B和C每次从盘子中拿出一张饺子皮包饺子。A就是饺子皮的生产者,B和C是饺子皮的消费者,而放饺子皮的盘子就是阻塞队列,如果A擀饺子皮速度比较快,很快盘子就满了,此时A老铁可以玩手机休息一会(阻塞),同理B和C包饺子速度快的话B和C老铁可以休息一会(阻塞),等待A老铁的饺子皮。这就是生产者消费者模型。
阻塞队列其实是一种数据结构,由于阻塞队列好处多,通常会把它单独封装成一个服务器程序,并且会在单独的服务器上部署,阻塞队列又叫消息队列
使用生产者、消费者模型的好处
1、有利于服务器之间的“解耦合”(解耦合就是降低模块之间的关联程度)
服务器A、B之间通过阻塞队列来进行通信,A不知道B的存在,A的代码中没有B的影子,B也不知道A的存在,代码中也没有A的影子,这样就解耦合了
2、通过阻塞队列,在遇到请求量突然暴增的情况,可以有效保护下游服务器,防止被冲垮
小知识:
1、为什么服务器收到的请求增多,就可能会崩溃?
服务器其实是一台电脑,上面提供了硬件资源,服务器每收到一个请求,就会执行相关代码来处理这个请求,执行代码会销毁一定的硬件资源,而硬件资源是有限的,当请求消耗的资源超过了机器的上限,机器就可能出问题
2、请求增多为什么上游服务器、队列不容易出问题,而下游服务器更容易出问题?
上游服务器只是一个“网关服务器”,它只负责数据转发,将收到的请求数据转发给其他服务器,转发操作消耗的硬件资源比较少,同理队列也是比较简单的代码程序,消耗的资源也比较少,上游服务器和队列负责转发就好了,而下游服务器要考虑的就多了,它要处理一系列的业务逻辑消耗的资源更多
生产者消费者模型虽好,但是也是有代价滴~
1、实现生产者消费者模型,就要更多的机器来部署这样的队列
2、服务器通信时间会延长
在Java标准库中提供了阻塞队列,叫BlockingQueue
BlockingQueue是一个接口,不能直接new,只能new实现了它的接口,实现了BlockingQueue接口的类包括ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue等
阻塞队列提供了put、take方法进行入队出队操作。具体用法可以看下面代码
public class demo25 {
public static void main(String[] args) throws InterruptedException {
BlockingQueue<String> queue = new ArrayBlockingQueue<String>(4);//参数表示容量
queue.put("A");
System.out.println("put成功");
queue.put("A");
System.out.println("put成功");
queue.put("A");
System.out.println("put成功");
queue.put("A");
System.out.println("put成功");
queue.put("A");
System.out.println("put成功");
}
}
上述代码实例化了一个容量为4的阻塞队列,但是我们进行了5次put操作,运行代码
可以看到,队列满了之后,进行的第五次put操作并没有成功,并且程序进入了阻塞状态,另外,如果我们往空的队列中取出元素,同样也会进入阻塞状态
public static void main(String[] args) throws InterruptedException {
BlockingQueue<String> queue = new ArrayBlockingQueue<String>(1);//参数表示容量
queue.put("A");
System.out.println("put成功");
queue.take();
System.out.println("take成功");
queue.take();
System.out.println("take成功");
}
运行代码
基于上述内容,我们来自己实现一个生产者消费者模型~
public class demo26 {
public static void main(String[] args) {
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(1000);
//生产者线程
Thread t1 = new Thread(() -> {
int i = 1;
while (true) {
try {
queue.put(i);
System.out.println("生产元素" + i);
i++;
// Thread.sleep(1000);//让生产慢点,每生产一个等待一会
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
//消费者线程
Thread t2 = new Thread(() -> {
while (true) {
try {
Integer i = queue.take();
System.out.println("消费元素" + i);
Thread.sleep(1000);//让消费者慢点
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t2.start();
}
}
通过在生产者线程或者消费者线程中添加sleep,可以控制生产和消费的速度。
如何自己实现一个阻塞队列?
public class MyBlockingQueue {
private String[] data = null;
//加上volatile避免内存可见性问题
private volatile int head = 0;//队头
private volatile int tail = 0;//队尾
private volatile int size = 0;//个数
public MyBlockingQueue(int capacity) {
data = new String[capacity];
}
public void put(String s) throws InterruptedException {
synchronized (this) {
while (size == data.length) {
//队列满了,需要阻塞进入阻塞状态
this.wait();
}
data[tail] = s;
tail++;
if (tail >= data.length) {
tail = 0;
}
size++;
this.notify();//put完毕,队列不为空,唤醒阻塞
}
}
public String take() throws InterruptedException {
String ret = "";
synchronized (this) {
while (size == 0) {
//队列为空,需要进入阻塞状态
this.wait();
}
ret = data[head];
head++;
if (head >= data.length) {
head = 0;
}
size--;
this.notify();//take完毕,队列不为满,唤醒阻塞
}
return ret;
}
}
上述wait为啥使用while:因为wait不仅仅能被notify唤醒,wait还能被Interrupt唤醒,为了避免这种情况,我们加上while循环,当wait被唤醒了,多判断一下size条件,保证逻辑不出错。
3. 线程池
“池”就是提前准备好的资源,随时可以拿来使用
当线程创建、销毁的次数越来越多,线程创建销毁的开销也会越来越大,线程池可以很好的解决这种问题,线程池会把线程提前从系统中创建、申请好,放到一个地方,后面需要使用线程的时候,直接从这个地方(池)取出,而不是重新申请,线程用完之后再放回到这个地方(池)。
为什么从线程池中取线程比从系统中申请线程更高效?
从系统中创建线程,是调用系统的api,由操作系统内核执行一系列逻辑,这个过程不可控,效率低;直接从线程池中取线程,这个过程都是用户自己控制的,这个过程是可控的,效率高。
Java标准库中提供了现有的线程池,通过ThreadPoolExecutor
构造方法如下:
1、corePoolSize表示核心线程数,maximumPoolSize表示最大线程数
线程池可以支持线程扩容,某个线程池初始情况可能有n个线程,实际情况中如果n个不够用,则会自动扩容增加线程的个数。在Java标准库的线程池中,线程分为核心线程(线程池中最少有多少个线程)和非核心线程(线程扩容的过程中新增的线程)核心线程数+非核心线程数的最大值就是最大的线程数。核心线程会始终存在线程池中,非核心线程在系统繁忙的时候创建,非繁忙的时候被销毁
2、keepAliveTime和unit表示非核心线程允许休息(摸鱼)的最大时间的数值和单位,keepAliveTime是数值,unit是单位,包含的单位如下
分别表示天、小时、微秒、毫秒、分钟、纳秒、秒
3、workQueue表示工作队列
线程池的工作过程是典型的生产者消费者模型,程序员使用的时候,通过形如submit这样的方法,把要执行的任务设定到线程池中,线程池内部的工资线程负责执行这些任务,此处有一个阻塞队列,submit把任务塞到阻塞队列中,工作线程从阻塞队列取任务。队列可以自行指定队列的容量和队列的类型。
4、ThreadFactory threadFactory表示线程工厂,工厂指"工厂设计模式"
工厂设计模式是一种在创建类的实例时使用的设计模式,工厂设计模式通过普通的静态方法来创建对象和初始化,而不是通过构造方法,这些static修饰的静态方法也称为工厂方法。有的工厂方法也会单独放在类当中,这样的类也称工厂类。threadFactory就是Thread类的工厂类通过这个类可以完成Thread的实例创建和初始化。这个参数一般使用ThreadFactory的默认值Executors.defaultThreadFactory() 即可
5、RejectedExecutionHandler handler,表示拒绝策略
Java标准库给出了4中不同的拒绝策略
策略 | 描述 |
---|---|
ThreadPoolExecutor.AbortPolicy | 添加任务的时候,直接抛出异常 |
ThreadPoolExecutor.CallerRunsPolicy; | 线程池解决执行,由submit的线程来执行添加的任务 |
ThreadPoolExecutor.DiscardOldestPolicy; | 把最老的任务去掉 |
ThreadPoolExecutor.DiscardPolicy; | 把最新添加的任务去掉 |
如果线程池的任务队列(阻塞队列)满了,还要往队列添加任务,我们可以选择上面的4中策略。
ThreadPoolExecutor使用起来比较麻烦,于是标准库又对这个类进一步封装成Executors。
Executors提供了一些工厂方法,可以更方便的构造线程池
例如,
Executors.CachedThreadPool();
Executors.FixedThreadPool();
Executors.newScheduledThreadPool();
Executors.newWorkStealingPool();
Executors.newSingleThreadExecutor();
主要了解Executors.CachedThreadPool和Executors.FixedThreadPool,
CachedThreadPool设置了非常大的最大线程数,这样可以对线程池不断的扩容;FixedThreadPool把核心线程数和最大线程数设置了一样的值,是固定值,不会扩容。
使用代码案例
public class demo28 {
public static void main(String[] args) throws InterruptedException {
ExecutorService service = Executors.newFixedThreadPool(4);
for (int i = 0; i < 100; i++) {
int id = i;
service.submit(() -> {
//执行的任务
Thread current = Thread.currentThread();
System.out.println("Hello Thread" + id + "," + current.getName());
});
}
//sleep一段时间,等任务执行完毕再shutdown
Thread.sleep(3000);
//将线程池中所有的线程都终止
service.shutdown();
System.out.println("程序退出");
}
}
解释一下这里的shutdown,线程池创建出的线程默认都是前台线程,虽然mian线程结束了,但是线程池中的这些前台线程仍然存在,shutdown可以把线程池中所有的线程都终止
这里介绍一个错误的代码,如图
这个i会有报错,报错的原因是“变量捕获”,定义的i在lambda表达式外面,而报错的这行代码是在lambda表达式里面,lambda捕获的变量是i,lambda捕获的变量应该是final修饰的或者事实final,但是很明显i是会变的,并不是事实final。
线程池的核心操作其实就是submit和shutdown,submit中描述线程执行的任务,shutdown则是终止线程。
那么接下来,我们就自己模拟实现一个简单的线程池。
class MyThreadPool {
//任务队列
private BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(1000);
//创建n个线程
public MyThreadPool(int n) {
for (int i = 0; i < n; i++) {
Thread t = new Thread(() -> {
//循环的从队列中取任务
while (true) {
try {
Runnable runnable = queue.take();
runnable.run();//获取任务之后,执行任务
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
//添加任务
public void submit(Runnable runnable) throws InterruptedException {
queue.put(runnable);
}
}
4. 定时器
定时器相当于闹钟,网络通信中,经常需要设定超时时间,我们可以通过定时器可以设定超时时间。
Java标准库中提供了定时器的实现,具体用法如下
public class demo30 {
public static void main(String[] args) {
Timer timer = new Timer();
//参数1表示要完成什么事,第二个参数表示完成这件事所用时间
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("Hello3");
}
}, 3000);
//timer不仅能安排一个任务
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("Hello2");
}
}, 2000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("Hello1");
}
}, 1000);
System.out.println("程序开始运行");
}
}
schedule方法的第一个参数是TimerTask对象描述了一个任务,第二个参数是整数,表示执行任务所用的时间。而TimerTask又实现了Runnable
接下来我们实现一个定时器
class MyTimerTask implements Comparable<MyTimerTask> {
//要执行的任务
private Runnable runnable;
//time表示任务是什么时候执行的
private long time;
public MyTimerTask(Runnable runnable, long delay) {
this.runnable = runnable;
this.time = System.currentTimeMillis() + delay;
}
public void run() {
runnable.run();
}
public long getTime() {
return time;
}
//实现Comparable接口,重写compareTo方法
@Override
public int compareTo(MyTimerTask o) {
//我们需要实现小堆,让时间最短的先执行
return (int) (this.time - o.time);
}
}
class MyTimer {
//使用堆来保存任务,管理多个任务
private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();
private Object locker = new Object();
public MyTimer() {
//创建线程,执行队列中的内容
Thread t = new Thread(() -> {
try {
while (true) {
synchronized (locker) {
//取任务前先确保不为空
while (queue.isEmpty()) {
locker.wait();
}
MyTimerTask current = queue.peek();
//当前时间10:30,任务时间12:00,不执行
//当前时间10:30,任务时间10:29,执行
//时间最靠前的先执行
if (System.currentTimeMillis() >= current.getTime()) {
//执行任务
current.run();
queue.poll();
} else {
//先不执行任务,等待一会~
locker.wait(current.getTime()-System.currentTimeMillis());//此处需要指定超时时间,如果时间到了就不等待
//闹钟时间减去当前时间-->等待的时间~~
}
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t.start();
}
public void schedule(Runnable runnable, long delay) {
synchronized (locker) {
MyTimerTask myTimerTask = new MyTimerTask(runnable, delay);
queue.offer(myTimerTask);
locker.notify();
}
}
}
常见的锁策略
这里谈到的锁是广义的锁,不是指具体的锁,所有的锁都可以往这些策略中套,之前提到的synchronized只是各种锁中的一种,是Java内置的锁。
乐观锁与悲观锁
乐观锁:加锁的时候,假设出现锁冲突(锁冲突,就是两个或者多个线程针对同一个锁对象加锁时,出现阻塞)的概率很多,因此接下来围绕加锁要做的工作会更少
悲观锁:加锁的时候,假设出现锁冲突的概率很少,因此接下来围绕加锁要做的工作会更多
synchronized这把锁是自适应的,初始情况下是乐观的,同时背后会统计锁冲突出现的次数,如果次数达到一定程度,乐观锁就会变成悲观锁
重量级锁与轻量级锁
重量级锁:加锁的时间开销大,要做的工作多(“悲观”的时候锁往往会比较“重”)
轻量级锁:加锁的时间开销小,要做的工作少(“乐观”的时候锁会往往比较“轻”)
挂起等待锁与自旋锁
挂起等待锁属于悲观锁/重量级锁的一种实现
自旋锁属于乐观锁/轻量级锁的一种实现
举个例子解释挂起等待锁和自旋锁:
博主我去追喜欢的女神,有一天我和女神表白(我尝试对女神加锁),但是女神表示她已经有对象了(女神这把锁已经被别的线程加锁了),这个时候,我有两种选择,1:我可以选择继续每天和女神说早安晚安,这种行为就叫自旋锁;2:我可以选择拉黑女神,先断绝联系,当某一天从朋友那边得知女神分手了,我再去联系女神,这种行为就叫挂起等待锁。
自旋锁是忙等,等待过程中不会释放cpu资源,并且会不停地检测锁是否被释放,一旦释放了就立刻有机会能够获取到锁;挂起等待锁,则相当于让出cpu资源,这时cpu可以用来做别的事情,但是挂起等待锁等待的时间可能需要等待更长的时间,因为我得知女神分手的消息,这中间我等了很久的时间,而且这中间女神是否谈了别的对象,这也不得而知~
自旋锁属于乐观锁:在锁冲突概率较低的情况下才能忙等,如果大量的线程在竞争同一个锁,一个线程拿到锁后,其他线程都在忙等,总的cpu的消耗就会非常大,竞争激烈也会导致有的线程要等很久才能拿到锁。
挂起等待锁属于悲观锁:锁竞争非常激烈的情况,可以预测拿到锁的概率也是很小的,这时不妨让出cpu来做其他事。
synchronized的自适应,轻量级锁,是基于自旋锁的方式实现,是JVM内部用户态代码实现的;重量级锁,则是基于挂起等待的方式实现的,是通过调用系统api,在系统内核中实现的。
公平锁与非公平锁
如图
当女神和现任男友分手之后,谁来上位?有两种方案,1:按照先来后到的顺序,追求女神时间更久的老铁先上位;2:所有老铁上位的概率都一样,各凭本事竞争。对于计算机来说,约定好方案1是公平的~
也就是说,按照先来后到的顺序进行加锁,这种加锁方式叫公平锁;按照概率均等的方式进行加锁,这种加锁方式叫非公平锁。
synchronized属于非公平锁,n个线程竞争同一个锁,其中一个线程拿到锁,等到这个线程释放锁之后,剩下的n-1个锁需要重新竞争各凭本事拿到锁,另外操作系统的内核针对锁的处理也是如此。如果需要使用公平锁,直接使用系统原生的锁即可(synchronized),如果需要使用非公平锁,可以利用优先级队列,记录等待时间,让等待久的线程先拿到锁。
可重入锁与不可重入锁
可重入锁在死锁问题中介绍过,如果一个线程对一把锁连续加锁两次,可能会出现死锁问题,如果把这把锁设为可重入就可以避免死锁问题。
可重入锁的特点:
1、记录当前是哪个线程持有这把锁
2、加锁的时候会判断申请加锁的线程是不是记录好的持有这把锁的线程
3、会有一个计数器,记录加锁的次数,从而确定何时释放锁
读写锁
读写锁就是把加锁分成两种情况:读加锁、写加锁
如果多个线程是同时读取同一个变量,此时没有线程安全问题;如果多个线程同时写或者一个线程读一个线程写,这时就会有线程安全问题。
读写锁提供了两种加锁api:加读锁,加写锁,但是解锁的api是一样的。
如果两个或者多个线程都是加读锁,此时不会产生锁冲突;两个或者多个线程加写锁,此时会产生锁冲突;如果两个线程一个线程是写锁,一个线程是读锁,此时会产生锁冲突。读写锁也是操作系统内置的锁,在Java中对此进行了封装,类名叫ReentrantReadWriteLock,这个类里面包含两个内部类
ReentrantReadWriteLock.ReadLock
ReentrantReadWriteLock.WriteLock
这两个类都提供了lock和unlock方法进行加锁和解锁,使用方法如下
public class demo32 {
public static void main(String[] args) {
ReentrantReadWriteLock outerReadLock = new ReentrantReadWriteLock();//先创建外部类
//读锁
ReentrantReadWriteLock.ReadLock readLock = outerReadLock.readLock();//通过外部类来创建内部类
readLock.lock();
readLock.unlock();
//写锁
ReentrantReadWriteLock.WriteLock writeLock = outerReadLock.writeLock();//通过外部类来创建内部类
writeLock.lock();
writeLock.unlock();
}
}
以上这些锁策略都是面试中经常涉及的内容,关于多线程相关的面试题,我会在文章最后总结~
synchronized的原理
synchronized的基本特点:
-
乐观、悲观是自适应的
-
重量、轻量是自适应的
-
自旋、挂起等待是自适应的
-
synchronized是非公平锁
-
synchronized是可重入锁
-
synchronized不是读写锁
1. 锁升级
synchronized加锁过程:开始时使用synchronized加锁,锁会处于“偏向锁”状态,遇到线程之间的锁竞争后,锁会升级成“轻量级锁”,进一步会统计锁竞争出现的次数,如果达到一定次数,锁会升级成“重量级锁”。
无锁->偏向锁->轻量级锁->重量级锁
关于偏向锁:偏向锁不是真的加锁,偏向锁只是做个标记,标记的过程非常轻量级。偏向锁是在推迟加锁的时机,在这个线程如果没有其他线程竞争这个锁,其实是不用加锁的,只是做个标记,解锁的时候也只是修改标记,都是非常轻量的操作;当很多线程开始竞争这把锁的时候,这个时候才进行加锁(升级成轻量级锁)。
2. 锁消除
锁消除是编译器的优化策略,编译器会判断程序员写的synchronized代码是否真的需要加锁,如果没必要加锁,编译器会自动把synchronized给去掉。
3.锁粗化
锁粗化也是编译器的优化策略,在synchronized大括号中,代码越多(执行次数),粒度越粗,代码越少粒度越细。锁粗化就是把很多个细粒度的锁,优化合并成一个粗粒度的锁。这样可以缩短等待时间。
CAS
CAS全称:Compare And Swap,也就是比较和交换。
比较内存和cpu寄存器中的内容,如果相同就进行交换(交换的是另一个寄存器的内容)。CAS的操作是针对一个内存的数据和两个寄存器中的数据,先比较内存和寄存器1中的值,看是否相等,如果不相等则无事发生;如果相等则交换内存和寄存器2的值,交换之后一般只关心内存中的内容,实际上就是把寄存器2中的值赋值给内存。上述操作是通过一个cpu指令来完成的,一个指令就代表这个操作是原子的,不可分割的操作,我们知道一个原子的操作不会出现线程安全问题,因此CAS给我们提供了多线程编程的新的思路,来解决一部分线程安全问题。
CAS使用场景:
1、实现原子类:
int、long这些基本数据类型的变量进行++、–操作都不是原子的,而基于CAS实现的原子类对这些基本数据类型进行了封装,使得这些基本数据类型可以以原子的方式完成++、–,这些原子类在Java中也有具体的实现~下面是使用案例:
public class demo33 {
private static AtomicInteger count = new AtomicInteger(0);
//相当于private static int count=0
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count.getAndIncrement();//相当于int类型的count++
// count.incrementAndGet();//相当于int类型的++count
// count.getAndDecrement();//相当于int类型的count--
// count.decrementAndGet();//相当于int类型的--count
// count.getAndAdd(1);//相当于相当于int类型的count+=1
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count.get());//通过get来拿到原子类内部的真实数据
}
}
上述代码如果使用的是基本数据类型int,会出现线程安全问题(前文讨论个),但是有了原子类,上述代码就不会出现线程安全问题,同样的,不止是int有原子类,其他的基本数据类型也有~
下面是伪代码是CAS的工程流程
public boolean CAS(address, expectValue, swapValue) {
//address是内存中的数据,expectValue是寄存器1中的数据,swapValue是寄存器2中的数据
if (&address == expectedValue) {//判断内存中的数据是否和寄存器1中的数据相同
&address = swapValue;//如果相同交换内存和寄存器2的数据
return true;
}
return false;
}
下面是原子类AtomicInteger中++的操作流程(基于CAS实现的)
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while (CAS(value, oldValue, oldValue + 1) != true) {
//
oldValue = value;
}
return oldValue;
}
}
2、实现自旋锁
下面伪代码是基于CAS实现的自旋锁
public class SpinLock {
private Thread owner = null;//记录当前锁是哪个线程持有,null表示处于解锁状态
public void lock() {
//通过CAS看当前锁是否被某个线程持有
//如果这个锁已经被别的线程持有,那么就⾃旋等待
//如果这个锁没有被别的线程持有,那么就把owner设为当前尝试加锁的线程
while (!CAS(this.owner, null, Thread.currentThread())) {
}
}
public void unlock() {
this.owner = null;
}
}
ABA问题
CAS是通过判断值是否相等,来区分是否有其他线程修改过这个变量,但是也有可能存在这种情况:一个线程把这个变量改了,另一个线程又把它改回去了,也就是:A->B->A。虽然CAS会有ABA问题,但是大部分情况下这个问题并不会带来bug。极端情况下,可以引入版本号来避免bug,规定版本号只能加不能减。
JUC(java.util.concurrent)常见类
Callable接口
Callable和Runnable类似,只不过Callable的call方法是有返回值的,而Runnable的run方法没有返回值。
案例:实现一个1+2+…+1000的功能
使用Runnable:
public class demo35 {
private static int result;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += i;
}
result = sum;
});
t1.start();
t1.join();
System.out.println(result);
}
}
使用Callable:
public class demo35 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//泛型参数表示返回值的类型
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += i;
}
return sum;
}
};//此时直接可以返回sum,不需要额外创建变量
//Thread不能直接传callable参数,只能借助FutureTask
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
System.out.println(futureTask.get());//通过FutureTask拿到结果,FutureTask相当于取餐的号码牌
}
}
ReentrantLock
ReentrantLock表示可重入锁,通过lock和unlock来加锁
使用案例:
public class demo36 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
lock.lock();
count++;
lock.unlock();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
lock.lock();
count++;
lock.unlock();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
ReentrantLock需要自己手动加锁和解锁,为了避免忘记解锁,可以使用finally,把unlock写到finally中。ReentrantLock中有一个tryLock方法,在加锁失败的情况下不会阻塞,而是直接返回,可以通过返回值来判断加锁是否成功。ReentrantLock提供了公平锁的实现,通过传入参数可以设置成公平锁。
信号量Semaphore
信号量是一个计数器,可以统计“可用资源”的个数,申请资源时,计数器-1(也称p操作);释放资源时,计数器+1(也称V操作);如果计数器为0,继续申请的话会出现阻塞。
public class demo37 {
public static void main(String[] args) throws InterruptedException {
//参数是可用资源的个数,也就是计数器的初始值
Semaphore semaphore = new Semaphore(4);
semaphore.acquire();//申请资源
System.out.println("申请资源");
semaphore.acquire();//申请资源
System.out.println("申请资源");
semaphore.acquire();//申请资源
System.out.println("申请资源");
semaphore.acquire();//申请资源
System.out.println("申请资源");
semaphore.release();//释放资源
System.out.println("释放资源");
semaphore.acquire();//申请资源
System.out.println("申请资源");
}
}
使用Semaphore也能解决线程安全问题
public class demo38 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(1);//信号量值为1,就相当于锁
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
try {
semaphore.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
semaphore.release();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
try {
semaphore.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
semaphore.release();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
通过availablePermits方法可以获取到可用资源个数
public class demo39 {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(4);
semaphore.acquire();
System.out.println(semaphore.availablePermits());
}
}
CountDownLatch
很多时候需要把一个大任务拆分成许许多多个小任务,通过多线程或者线程池执行任务。如何才能知道所有的任务都执行完毕?可以使用CountDownLatch,代码案例如下
public class demo40 {
public static void main(String[] args) throws InterruptedException {
ExecutorService service = Executors.newFixedThreadPool(4);//创建线程池
CountDownLatch count = new CountDownLatch(20);//拆分成20个任务
for (int i = 0; i < 20; i++) {
int id = i;
service.submit(() -> {
System.out.println("下载 " + id + " 任务");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("下载 " + id + " 任务完毕");
count.countDown();
});
}
//当所count收到20个完成,此时所有的任务都完成了
count.await();//相当于all wait
System.out.println("所有任务都完成");
}
}
线程安全的集合类
Java中的集合类如ArrayList、Queue、HashMap等都是线程不安全的,而Vector、Stack、Hashtable虽然是线程安全的,它们内置了synchronized但是在多线程环境下也是不推荐使用的。那么怎么如果想在多线程环境下使用集合类,如何避免线程安全问题?
解决方案:
1、自己加锁
2、如果想在多线程环境下使用ArrayList、LinkedList等List这类的集合类,可以使用带锁的List:
List<Integer> list1= Collections.synchronizedList(new ArrayList<>());
List<Integer> list2 = Collections.synchronizedList(new LinkedList<>());
当然也可以使用CopyOnWrite集合类,例如CopyOnWriteArrayList,这个集合类没有加锁,而是通过写时拷贝避免线程安全问题。
CopyOnWriteArrayList<Integer> copy = new CopyOnWriteArrayList<>();
但是写时拷贝有缺点:无法解决多个线程同时修改的情况产生的线程安全问题,如果数据量大,拷贝效率低。
3、如果想在多线程环境下使用队列,可以使用BlockingQueue
BlockingQueue<Integer> queue = new ArrayBlockingQueue<Integer>(4);//队列的容量
4、如果想在多线程环境下哈希表,推荐使用ConcurrentHashMap
ConcurrentHashMap<String,String> map = new ConcurrentHashMap<>();
ConcurrentHashMap的优点:
1、优化锁了的粒度
Hashtable的加锁是直接给put、get方法加锁(也就是给this加锁),整个的Hashtable对象就是一把锁,任何一个对该对象的操作都会发生锁竞争。
而我们的ConcurrentHashMap不一样,ConcurrentHashMap是给每个哈希表中的链表进行加锁,也就是说它不是一把锁,而是很多把锁。
如果是只有一把锁,多个线程同时修改不同的链表是不会产生线程安全问题的,但是仍然会产生阻塞。
2、ConcurrentHashMap引入了CAS原子操作,修改size(哈希表中的元素个数)这样的操作是不会加锁的,而是借助CAS完成。
3、对读操作做了处理,1和2是针对写操作进行处理,针对读操作通过volatile等确保读取的数据不是“半成品”
4、针对哈希表扩容做了优化
普通的哈希表扩容是重新创建一个哈希表,把原来的数据都搬过去,而且是一次性搬过去。ConcurrentHashMap是创建新的哈希表,每次操作都只搬运一部分数据,减少了开销。
至此,和多线程相关的知识就基本上介绍完毕了,后续还会更新多线程相关的面试题~敬请期待哦!