面试:再见多线程!

Java大联盟

共 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算法来更新数据,而不需要加锁。

  • 使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。

  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。


问题二:死锁

死锁是一个比较常见也比较难解决的问题,当多个线程等待同一个不会释放的资源时,就会发生死锁。避免死锁可以参考下面的思路。

避免死锁的方法:

  1. 避免一个线程同时获取多个锁。

  2. 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。

  3. 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。

  4. 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。

问题三:资源限制

资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。例如,服务器的带宽只有2Mb/s,某个资源的下载速度是1M每秒,系统启动10个线程下载资源,下载速度不会变成10Mb/s

解决资源限制思路:

  1. 对于硬件资源限制,可以考虑使用集群并行执行程序。

  2. 对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和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()恢复,已经被弃用,

缺点:

  1. 独占,使用不当很容易让公共的同步对象独占,使得其他线程无法访问。

  2. 不同步:线程暂停容易导致不同步。

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;        //语句1y = 0;        //语句2flag = true;  //语句3x = 4;        //语句4y = -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();   //语句1inited = true;             //语句2
//线程2:while(!inited ){ sleep();}
doSomethingwithconfig(context);

前面举这个例子的时候,提到有可能语句2会在语句1之前执行,那么久可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。

这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕。


与 synchronized 对比

  1. volatile是线程同步的轻量实现,只能修饰变量,性能高于synchronized

  2. volatile保证可见性,不保证原子性【一旦其修饰的变量改变,其余的线程都能发现,因为会强制从公共堆栈取值】,synchronized保证原子性,间接保证可见性,因为他会将私有内存和公共内存的值同步

例如:i++操作,实际上不是原子操作,他有3步:

(1).从内存取i值

(2).计算i的值

(3).将i的新值写到内存

多个线程执行时,使用volatile,可能导致数据脏读,进而出现错误。

  1. 多线程访问volatile不会阻塞,而synchronized会

  2. 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;
//线程1while(!flag){ doSomething();}
//线程2public 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)


推荐阅读

1、Spring Boot+Vue项目实战

2、B站:4小时上手MyBatis Plus

3、一文搞懂前后端分离

4、快速上手Spring Boot+Vue前后端分离


楠哥简介

资深 Java 工程师,微信号 nnsouthwind

《Java零基础实战》一书作者

腾讯课程官方 Java 面试官今日头条认证大V

GitChat认证作者,B站认证UP主(楠哥教你学Java)

致力于帮助万千 Java 学习者持续成长。




有收获,就在看 
浏览 18
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报