一、多线程并发可能遇到的问题
多线程并发执行可能会导致一些问题:
安全性问题:在单线程系统上正常运行的代码,在多线程环境中可能会出现意料之外的结果。
活跃性问题:不正确的加锁、解锁方式可能会导致死锁或者活锁问题。
性能问题:多线程并发即多个线程切换运行,线程切换会有一定的消耗并且不正确的加锁。
1. 安全性问题
多线程的三大特性:原子性、可见性、有序性。如果不满足这三大特性,就可能产生线程安全问题。
案例:需求现有100张火车票,两个窗口同时售卖火车票,请用多线程模拟抢票效果。
class ThreadTrain1 implements Runnable{
private int train1Count = 100;//火车票总数 总共100张
@Override
public void run() {
//为了能够模拟程序一直在抢票
while (train1Count > 0){
try {
//线程从运行状态 -> 休眠状态 -> CPU执行权让给其他线程
//有两个线程同时释放了CPU执行权 两个线程又会同时从就绪到运行状态 去做train1Count -- 冲突的概率非常大
Thread.sleep(30);
} catch (InterruptedException e) {
e.printStackTrace();
}
//出售车票
System.out.println(Thread.currentThread().getName()+"出售第"+(100-train1Count +1)+"票");
train1Count -- ;
}
}
}
public static void main(String[] args) {
ThreadTrain1 threadTrain1 = new ThreadTrain1();
Thread t1 = new Thread(threadTrain1,"窗口1");//两个线程共享一个数据
Thread t2 = new Thread(threadTrain1,"窗口2");
t1.start();
t2.start();
}
执行结果:
当多个线程同时共享同一个全局变量,做写的操作时可能会受到其他线程的干扰,导致数据误差。这种现象即线程安全问题。
如上图,每个线程都有自己的私有工作内存,工作内存和主内存之间需要通过load/store进行交互。线程1可能在自己的工作内存中进行了计算操作,但还没有及时将新值刷新到主内存,这样线程2再操作同一个变量时就会产生问题。
除非使用volatile
或利用锁机制
显示的告诉处理器需要确保线程之间的可见性。
解决思路
- 不在线程之间对全局共享数据做操作
即限制变量同一时刻只能在单个线程中访问。实现方式:
- 线程封闭
保证变量只能被一个线程可以访问到。可以通过Executors.newSingleThreadExecutor()
实现。- 栈封闭
栈封闭即使用局部变量
。局部变量只会存在于本地方法栈
中,不能被其他线程访问,因此也就不会出现并发问题。所以如果可以使用局部变量就优先使用局部变量
。- ThreadLocal封闭
ThreadLocal是Java提供的实现线程封闭
的一种方式,ThreadLocal内部维护了一个Map,Map的key是各个线程,而Map的值就是要封闭的对象。每个线程中的对象都对应着Map中一个值,也就是ThreadLocal
利用Map实现了对象的线程封闭。
- 多线程之间同步
synchronized
或使用锁(lock
)
内置锁:jdk自带的锁—synchronized
synchronized(对象){
可能会发生线程冲突的代码;
}
显示锁:人为添加的锁–Lock锁
2. 活跃性问题
活跃性问题包括但不限于死锁、活锁、饥饿等。
死锁:死锁发生在一个线程需要获取多个资源的时候,这时由于两个线程互相等待对方的资源而被阻塞,死锁是最常见的活跃性问题。
活锁:当多个线程都拿到资源却又相互释放不执行,出现了相互谦让,都主动将资源释放给别的线程使用,这样这个资源在多个线程之间跳动而又得不到执行,这就是活锁——任何一个线程都无法继续执行。
饥饿:线程无法访问到它需要的资源而不能继续执行时,就是处于饥饿状态。
常见有几种场景:
- 高优先级的线程一直在运行消耗CPU,所有的低优先级线程一直处于等待;
- 一些线程被永久堵塞在一个等待进入同步块的状态,而其他线程总是能在它之前持续地对该同步块进行访问;
3. 性能问题
前面讲到了线程安全和死锁、活锁这些问题会影响多线程执行过程,如果这些都没有发生,也并不是只要用多线程性能就高,主要因为多线程有创建线程
和线程上下文切换
的开销。
线程的创建和销毁都需要时间,操作系统需要给它分配内存、列入调度等,当有大量的线程创建和销毁时,那么这些时间的消耗则比较明显,将导致性能上的缺失。并且大量的线程的创建和销毁很容易导致GC频繁的执行,从而发生内存抖动现象,对移动端来讲,最大的影响就是造成页面卡顿。
线程创建完之后,还会遇到线程上下文切换。
CPU是很宝贵的资源速度也非常快,为了保证雨露均沾,通常为给不同的线程分配时间片,当CPU从执行一个线程切换到执行另一个线程时,CPU需要保存当前线程的本地数据,程序指针等状态,并加载下一个要执行的线程的本地数据,程序指针等,这个开关被称为『上下文切换』。
解决办法:有效利用现有的处理资源,重用已有的线程,从而减少线程的创建和销毁(这就需要使用线程池,线程池的基本作用就是进行线程的复用)。
3. 线程问题的深层原因
-
线程调度模型
有两种调度模型:分时调度模型和抢占式调度模型。
分时调度模型是指让所有的线程轮流获得cpu的使用权,并且平均分配每个线程占用的CPU的时间片。
Java虚拟机采用抢占式调度模型,是指优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃CPU。
-
JAVA内存模型
Java内存模型主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
Java内存模型分为主内存和工作内存。主内存所有线程共享,工作内存每个线程单独拥有,不共享。
-
指令重排
在我们编程过程中,习惯性程序思维认为程序是按我们写的代码顺序执行的,举个例子来说,某个程序中有三行代码:
int a = 1; // 1 int b = 2; // 2 int c = a + b; // 3
从程序员角度执行顺序应该是1 -> 2 -> 3,实际经过编译器和CPU的优化很有可能执行顺序会变成 2 -> 1 -> 3(注意这样的优化重排并没有改变最终的结果)。类似这种不影响单线程语义的乱序执行我们称为指令重排。(后面讲Java内存模型也会讲到这部分)
目标:提高运行速度。
起因:只要程序的最终结果与严格串行环境中执行的结果相同,那么所有操作都是允许的。
重排序的问题是一个单独的主题,常见的重排序有3个层面:
- 编译级别的重排序,比如编译器的优化—编译器指令重排
- 指令级重排序,比如CPU指令执行的重排序—CPU指令重排
- 内存系统的重排序,比如缓存和读写缓冲区导致的重排序—内存系统重排
二、Thread和Runable
1 创建线程的两种方式
通过继承Thread实现
package com.yt.multithreading.thread;
/**
* @Author YT
* @Date 2022/3/25 15:36
**/
public class MyFirstThread {
private static class FirstThread extends Thread{
//这个run方法定义了我们自己的线程具体运行的代码
@Override
public void run() {
System.out.println("当前运行的线程是:"+Thread.currentThread().getName());
}
}
public static void main(String[] args) {
FirstThread firstThread = new FirstThread();
firstThread.run();
System.out.println("main方法中的线程:"+Thread.currentThread().getName());
}
}
private static class FirstThread extends Thread{
@Override
public void run() {
System.out.println("当前运行的线程是:"+Thread.currentThread().getName());
}
}
public static void main(String[] args) {
Thread t1 = new FirstThread();
t1.setName("T1");//将此线程的名字改为指定参数
FirstThread t2 = new FirstThread();
t2.setName("T2");
FirstThread t3 = new FirstThread();
t3.setName("T3");
t1.start();
t2.start();
t3.start();
}
每一次运行的顺序都不一样,表明线程的运行具有随机性:
- 调用
start()
方法后,程序通知JVM——“我已经准备好了,可以开始运行了” - JVM异步的调用线程对应的
run()
方法
start()方法的调用顺序不代表线程的run()方法运行顺序。
用Runable接口的方式实现多线程
package com.yt.multithreading.thread;
/**
* @Author YT
* @Date 2022/3/25 19:24
**/
public class UseRunableThread {
private static class UseRunableTest implements Runnable{
@Override
public void run() {
System.out.println("当前运行的线程是:"+Thread.currentThread().getName());
}
}
public static void main(String[] args) {
Thread thread = new Thread(new UseRunableTest());
//Thread thread = new Thread(() -> System.out.println("当前运行的线程是:"+Thread.currentThread().getName()));
thread.start();
System.out.println("main方法中的线程:"+Thread.currentThread().getName());
}
}
2 Thread类和Runable接口的关系
对于继承Thread
类方式创建的线程,启动线程本质上执行过程是经过start()
—>start0()
—>run()
这样一个执行过程。
对于实现Runable接口的方式,创建线程对象时首先调用init()
方法,Thread类中的target初始化为实现业务逻辑的Runable实现。启动线程本质上经历的过程:start(
) --> start0()
--> run()
,但是再执行run
方法的时候会判断tartget
是否为空,决定执行的run
方法到底是谁的run
方法。
Thread
类实现了Runnable
接口,Runnable
接口里只有一个抽象的run()
方法。说明Runnable
不具备多线程的特性。Runnable
依赖Thread
类的start
方法创建一个子线程,再在这个子线程里调用run()
方法,才能Runnable
接口具备多线程的特性。
无论继承Thread类还是实现Runnable接口的方式,最终线程执行时都是调用你所覆写的run方法。实质上线程自身的逻辑都在Thread类中,Runable实现类只是线程执行流程中的一个步骤。