面试:再见多线程!
共 9010字,需浏览 19分钟
·
2022-04-28 00:19
Java大联盟 致力于最高效的Java学习
关注
B 站搜索:楠哥教你学Java
获取更多优质视频教程
应用场景
1、异步处理,例如:发邮件、发微博、写日志等;
2、分布式计算
3、定期执行一些特殊任务:如定期更新配置文件,任务调度(如quartz),一些监控用于定期信息采集等
4、TOMCAT处理多用户请求。
5、针对特别耗时的操作。多线程同步执行可以提高速度。例如:定时向大量(100w以上)的用户发送邮件。
多线程编程面临的挑战及解决思路
问题一:上下文切换
并发不一定快于串行,因为会有切换上下文的开销。【切换上下文:单核并发时,cpu会使用时间片轮转实现并发,每一次轮转,会保留当前执行的状态】。
解决上下文切换开销的办法:
无锁并发编程:多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
CAS算法:Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
问题二:死锁
死锁是一个比较常见也比较难解决的问题,当多个线程等待同一个不会释放的资源时,就会发生死锁。避免死锁可以参考下面的思路。
避免死锁的方法:
避免一个线程同时获取多个锁。
避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
问题三:资源限制
资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。例如,服务器的带宽只有2Mb/s,某个资源的下载速度是1M每秒,系统启动10个线程下载资源,下载速度不会变成10Mb/s
解决资源限制思路:
对于硬件资源限制,可以考虑使用集群并行执行程序。
对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket连接复用,或者在调用对方webservice接口获取数据时,只建立一个连接。
基础示例
实现多线程基本的实现方式就是如下两种:
继承Thread类;
实现Runnable接口;
实际使用时,会用到线程池,还会用spring管理线程池,下面使用多线程完成几个小例子。
示例一:多线程使用reentrantLock实现交替打印奇数偶数,代码见压缩包:
示例二:4个线程,两个存钱,两个取钱
示例三:spring管理线程池配置
停止线程
终止线程有三种方式:
(1)使用退出标志,run()执行完以后退出【抛出异常或者return】
(2)使用stop强行停止线程,不推荐,会导致当前任务执行到一半突然中断,出现不可预料的问题;而且stop和suspend以及resume一样是过期作废的方法
(3)使用interrupt中断线程
interrupt()方法不会真的停止线程,而是会记录一个标志,这个标志,可以由下面的两个方法检测到。
Thread.interrupted( ) 测试当前线程是否停止,但是它具有清除线程中断状态功能,如第一次返回true,第二次调用会返回false;
Thread.isInterrupted( ),仅返回结果,不清除状态。重复调用会结果一致。
基于上面的逻辑,可以根据标志来在run()里面状态,然后再使用interrupt()来使代码停止,停止代码可以使用抛出异常的方式。
如果在sleep里面抛出异常停止线程,会进入catch,并清除停止状态,使之变成false;
stop()暴力停止,已经被作废,建议不使用;
使用stop的方法带来的问题:
1.执行到一半强制停止,可能清理工作来不及;
2.对锁定的对象进行了解锁,导致数据不同步,不一致。
return方法停止线程:
其实就是使用 打标记+return 替换 打标记+抛异常
暂停线程与恢复线程
suspend()暂停,resume()恢复,已经被弃用,
缺点:
独占,使用不当很容易让公共的同步对象独占,使得其他线程无法访问。
不同步:线程暂停容易导致不同步。
yield():作用是放弃当前cpu资源,将他让给其他任务去占用cpu;但是放弃的时间不确定,有可能刚放弃,马上又获得cpu时间片;直接在run方法里面使用即可。 线程优先级
多个线程可以设置优先级。
优先级设置:
setPriority( ) 方法;分为1-10,10个等级,超过这个范围,会抛出异常。
java线程优先级可以继承,A线程启动B线程,那么B与A的优先级是一样的。
优先级高的绝大多数会先执行,但结果不是百分之百的。
对象以及变量访问
在run里面执行的方法,如果是同步的,则不会有线程安全问题,使用synchronized关键字即可保证同步。
synchronized持有的锁是对象锁,如果多个线程访问多个对象,则JVM会创建多个锁。【多个对象,多个锁,此处对象是指加了synchronized关键字的方法所在的类也就是创建线程时传入的对象,例如:
Thread a = new Thread(object1);
Thread b= new Thread(object2);
a.start();
b.start();
这种情况下线程a和b持有的是两个不同的锁。
赃读
读取全局变量时,此变量已经被其他线程修改过了,就会出现赃读。
synchronized 实际上是对象锁
现有A,B两个线程,C对象,C拥有加了synchronized关键字的方法X1()和X2(),以及未加synchronized关键字的X3()方法。
当A线程访问X1方法时,B线程想访问X1,必须等待A执行完,释放对象锁;
当A在访问X1,B想访问X3(),无需等待,直接访问。
当A在访问X1,B想访问X2(),需要等待A执行完。
synchronized 锁重入
在synchronized方法内,调用本类的其他的synchronized方法时,总是可以成功。
如果不可重入的话,会造成死锁;
可重入锁,支持在父子类继承的环境:子类可以通过"可重入锁"调用父类的同步方法。
异常会释放锁
当一个线程执行出现异常,会释放他所持有的所有锁。
同步不具有继承性
父类中A()方法是synchronized的,子类中的A方法,不会是同步的,需要手动加上。
synchronized 同步语句块
synchronized(this){
//...同步的代码块...
}
synchronized声明方法的弊端:
A线程调用同步方法执行长时间任务时,B线程需要等待很久。
解决办法:可以使用synchronized同步语句块。
synchronized可以修饰代码块。使用synchronized修饰需要保持同步部分代码,其余部分异步,借此提高运行效率。
synchronized 代码块间的同步性
A对象,拥有X1和X2两个synchronized 同步代码块,
那么,B线程在访问X1时,C线程也无法访问X2,需要等待B线程释放对象锁。
此处与synchronized 修饰方法时一样。他们持有的都是对象锁。
任意对象作为监视器
synchronized 修饰的代码块时,如果传入this,则会监视当前对象,加锁时会对当前整个对象加锁;
例如:对象A有方法X1() 和X2() ,如果在X1和X2里有一段同步代码块,并且synchronized(this)传入的都是this对象,那么在B线程访问X1的同步代码块时,C线程也无法X2的同步代码块。
如果传入的不是this,而是另外的对象,则C可以访问X2的同步代码块。
要保证传入其他监视对象时的成功同步,必须保证在调用时,监视对象是一致的,不能每次都new一个监视对象,否则会导致变成异步的。
脏读问题
有时候,仅仅使用synchronized 修饰方法,并不能保证正确的逻辑。
比如,两个synchronized 修饰的方法add() 与getSize() ,他们分别是对list进行读与写的操作,此时两个线程先后调用这两个方法,会导致结果超出预期。
解决:
add()方法中,synchronized 改成去修饰代码块,并且传入监视对象list;
synchronized(list){
//--- add ---
}
静态同步synchronized 方法,与synchronized(class)代码块
synchronized 加在static 静态方法上,就是对当前.java文件对应的class类进行持锁。
synchronized static等同于synchronized (object.class) 可以对该类的所有对象起作用,即:即使需要new不同的对象,也可以保持同步。
String 的常量池特性
一般不使用String变量来作为锁的监视对象,当对一个String变量持有锁时,如果两个访问线程传入的String变量值一样,会导致锁不被释放,其中一个线程无法执行。
可以使用对象来存储相应的变量解决此问题。
volatile 关键字
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile 修饰之后,那么就具备了两层语义:
1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
2)禁止进行指令重排序。
volatile 保证有序性
volatile关键字禁止指令重排序有两层意思:
1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
2)在进行指令优化时,不能将在对volatile变量的读操作或者写操作的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
可能上面说的比较绕,举个简单的例子:
//x、y为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。
那么我们回到前面举的一个例子:
//线程1:
context =loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep();
}
doSomethingwithconfig(context);
前面举这个例子的时候,提到有可能语句2会在语句1之前执行,那么久可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。
这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕。
与 synchronized 对比
volatile是线程同步的轻量实现,只能修饰变量,性能高于synchronized
volatile保证可见性,不保证原子性【一旦其修饰的变量改变,其余的线程都能发现,因为会强制从公共堆栈取值】,synchronized保证原子性,间接保证可见性,因为他会将私有内存和公共内存的值同步
例如:i++操作,实际上不是原子操作,他有3步:
(1).从内存取i值
(2).计算i的值
(3).将i的新值写到内存
多个线程执行时,使用volatile,可能导致数据脏读,进而出现错误。
多线程访问volatile不会阻塞,而synchronized会
volatile是解决变量在多个线程之间的可见性,synchronized是保证多个线程之间资源的同步性。
volatile 的实现原理
1.可见性
处理器为了提高处理速度,不直接和内存进行通讯,而是将系统内存的数据独到内部缓存后再进行操作,但操作完后不知什么时候会写到内存。
如果对声明了volatile变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写会到系统内存。这一步确保了如果有其他线程对声明了volatile变量进行修改,则立即更新主内存中数据。
但这时候其他处理器的缓存还是旧的,所以在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查 自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。这一步确保了其他线程获得的声明了volatile变量都是从主内存中获取最新的。
2.有序性
Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。
volatile 的应用场景
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
1)对变量的写操作不依赖于当前值
2)该变量没有包含在具有其他变量的不变式中
下面列举几个Java中使用volatile的几个场景:
①.状态标记量
volatile booleanflag = false;
//线程1
while(!flag){
doSomething();
}
//线程2
public voidsetFlag() {
flag = true;
}
根据状态标记,终止线程。
②.单例模式中的doublecheck
class Singleton {
private volatile static Singleton instance= null;
private Singleton() {}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
为什么要使用volatile 修饰instance?
主要在于instance= new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情:
1.给 instance 分配内存
2.调用Singleton 的构造函数来初始化成员变量
3.将instance对象指向分配的内存空间(执行完这步 instance就为非 null 了)。
但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。
线程间通信
1、等待通知机制
wait 使线程暂停,而notify 使线程继续运行。还有notifyAll() 方法。
wait()和notify(),两个方法来实现等待通知机制;
注意:(1)两个方法在调用时都需要持有当前对象的对象锁,所以都只能在同步代码块或者同步方法里面调用,如果不是会抛出异常。
2、wait
(2)wait方法会将当前线程置入“预执行队列”,并在wait()所在代码行停止执行,直到接到notify(),或者被中断;
(3)执行wait()后,当前线程释放锁;
3、notify
(1)如果多个线程在wait,那么会由线程规划器,挑选一个执行notify,并使他获取该对象的对象锁;
(2)noitfy执行之后,当前线程不会立马释放该对象锁,wait状态的线程也不能立马获得该对象锁,要等执行notify()方法的线程将程序执行完,也就是退出synchronized代码块之后才会释放锁,并让wait获得。
(3)多个wait的线程,第一个获取到notify并执行完之后,其余的wait状态的线程如果没有被通知,还是会一直阻塞。
wait 之后自动释放锁,notify 之后不会立马释放锁
interrupt 方法与 wait
当线程在wait状态时,调用对象的interrupt()方法,会抛出异常。
(1)执行完同步代码块之后,会释放当前对象的锁
(2)执行同步代码块过程中,抛出异常也会释放锁
(3)执行wait()之后,也会释放锁
wait(long)
执行wait(5000)后,首先会等待5秒,如果5秒内没有收到通知,会自动唤醒线程,退出wait状态。
通过管道进行线程间通信
4个类进行线程间通信:
(1)字节流:PipedInputStream和PipedOuputStream
(2)字符流:PipedReader和PipedWriter
使用语法:
输出:PipedOuputStream
PipedOuputStream out;
out.write();
out.close();
join 方法
在主线程中调用子线程的join方法,可以让主线程等待子线程结束之后,
再开始执行join()之后的代码。
join可以使线程排队运行,类似于synchronized的同步;区别在于join在内部使用wait()等待,而synchronized使用对象监视器原理同步。
注意
在join过程中,如果当前线程对象被中断,则当前线程出现异常,子线程会继续运行;
join(long)
long参数是设定等待时间,使用sleep(long)也可以等待,但二者是有区别的:
join(long),内部是使用的wait(long),等待时会释放锁;
sleep(long)等待时不会释放锁。
ThreadLocal
变量值的共享可以使用public static;
如果想让每个线程都有自己的共享变量。可以使用ThreadLocal;ThreadLocal可以看做全局存放数据的盒子,盒子中可以存储每个线程的私有数据;
使用时,只需新建一个类继承ThreadLocal即可实现,不同的线程在这个类中取到各自隔离的变量。
InheritableThreadlocal
InheritableThreadlocal可以在子线程中取得父线程继承下来的值。
使用注意:如果子线程取得值的同时,主线程将值进行了修改,那么取到的还是旧值。
Lock 的使用
ReenTrantLock可以和synchronized一样实现多线程之间的同步互斥,ReenTrantLock类在功能上还更加强大,有嗅探锁定,多路分支通知等。
使用:
privateLock lock = new ReenTrantLock();
try{
//加锁
lock.lock();
//解锁
lock.unlock();
}catch(Exception e){
}
ReenTrantLock 结合 Condition 实现等待/通知
功能上与synchronized结合wait/notify一样,而且更加灵活;
一个Lock对象可以创建多个Condition(即对象监视器)实例,线程对象可以注册在指定的condition中,从而可以有选择性的进行线程通知,在线程调度上更加灵活。
而在wait/notify时,被通知的线程是JVM 随机选择的,不如ReenTrantLock 来得灵活。
synchronized相当于整个lock对象中只有一个单一的condition,所有的线程都注册在它上面,线程开始notify时,需要通知所有的waitting线程,没有选择权,效率不高。
使用之前,必须使用lock.lock()获取对象锁。
private Condition condition = lock.newCondition();
try{
condition.await();
}catch(Exception e){
}
其实使用上:wait()/notify()/notifyAll()相当于Condition类
里面的await()/signal()/signalAll()
wait(long timeout)相当于await(long time,TimeUnit unit)
楠哥简介
资深 Java 工程师,微信号 nnsouthwind
《Java零基础实战》一书作者
腾讯课程官方 Java 面试官,今日头条认证大V
GitChat认证作者,B站认证UP主(楠哥教你学Java)
致力于帮助万千 Java 学习者持续成长。