【面试】多线程知识点
一、创建多线程得四种方式
1、继承Thread类的方式:
1.创建一个继承于Thread类的子类
2.重写Thread类的run() --> 将此线程执行的操作声明在run()中
3.创建Thread类的子类的对象
4.通过此对象调用start():①启动当前线程 ②调用当前线程的run()
class MyThread extends Thread {
// 线程执行体
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}
class TestThread {
public static void main(String[] args) {
MyThread thread1 = new MyThread();// 创建一个新的线程thread1 此线程进入新建状态
MyThread thread2 = new MyThread();
thread1.start(); // 调用start()方法,使线程进入就绪状态
thread2.start();
}
}
问题:
- 我们启动一个线程,必须调用start(),不能调用run()的方式启动线程
- .如果再启动一个线程,必须重新创建一个Thread子类的对象,调用此对象的start()
2、实现Runnable接口的方式:
1.创建一个实现了Runnable接口的类
2.实现类去实现Runnable中的抽象方法:run()
3.创建实现类的对象
4.将此对象作为参数传递到Thread类的构造器中,创建Thread类的对象
5.通过Thread类的对象调用start()
class MyThread implements Runnable{
// 线程执行体
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}
class TestThread {
public static void main(String[] args) {
MyThread myRunnable = new MyThread();// 创建一个Runnable实现类的对象
Thread thread1 = new Thread(myRunnable); // 将myRunnable作为Thread target创建新的线程
Thread thread2 = new Thread(myRunnable);
thread1.start(); // 调用start()方法,使线程进入就绪状态
thread2.start();
}
}
两种方式的对比:
开发中优先选择:实现Runnable接口的方式
原因:1. 实现的方式没类的单继承性的局限性;2. 实现的方式更适合来处理多个线程共享数据的情况
联系:public class Thread implements Runnable
相同点:1. 两种方式都需要重写run(),将线程要执行的逻辑声明再run();2. 两种方式要想启动线程,都是调用Thread类中的start()
3、实现Callable接口的方式
1.创建一个实现Callable的实现类
2.实现call方法,将此线程需要执行的操作声明在call()中
3.创建Callable接口实现类的对象
4.将此Callable接口实现类的对象作为参数传递到FutureTask构造器中,创建FutureTask的对象
5.将FutureTask的对象作为参数传递到Thread类的构造器中,创建Thread对象,并调用start()
6.获取Callable中call方法的返回值
public class ThreadTest {
public static void main(String[] args) {
// 创建CallableThread对象
Callable<Integer> myCallable = new CallableThread();
//使用FutureTask来包装CallableThread对象
FutureTask<Integer> ft = new FutureTask<Integer>(myCallable);
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + " " + i);
if (i == 30) {
// FutureTask对象作为Thread对象的target创建新的线程
Thread thread = new Thread(ft);
thread.start();// 线程进入到就绪状态
}
}
System.out.println("主线程for循环执行完毕..");
try {
int sum = ft.get();
System.out.println("子线程的返回值:" + sum);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
class CallableThread implements Callable<Integer> {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + " " + i);
sum += i;
}
return sum;
}
}
如何理解实现Callable接口的方式创建多线程比实现Runnable接口创建多线程方式强大?
1.call()可以有返回值的。
2.call()可以抛出异常,被外面的操作捕获,获取异常的信息
3.Callable是支持泛型的
4、使用线程池的方式
使用线程池的好处:
- 提高响应速度(减少了创建新线程的时间)
- 降低资源消耗(重复利用线程池中线程,不需要每次都创建)
- 便于线程管理
线程池属性:
corePoolSize: 核心线程数量
maxmumPoolSize: 最大线程数
keepAliveTime: 线程没有任务时最多保持多长时间后会终止
TimeUnit:线程活动保持时间的单位
创建线程池:ExecutorService 和 Executors
Executors.newFixedThreadPool(n); 创建一个可重用固定线程数的线程池
public class ThreadTest {
public static void main(String[] args) {
// 创建固定大小线程池
ExecutorService threadPool = Executors.newFixedThreadPool(5);
for (int i = 0; i < 5; i++) {
MyRunnable myRunnable = new MyRunnable();
// 执行任务并获取Future对象
threadPool.execute(myRunnable);
}
//关闭线程池
threadPool.shutdown();
System.out.println("主线程for循环执行完毕..");
}
}
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("通过线程池方式创建的线程:" + Thread.currentThread().getName() + " ");
}
}
void execute(Runnable command) :执行任务/命令,没有返回值,一般用来执行Runnable
Future submit(Callable task):执行任务,有返回值,一般又来执行Callable
void shutdown():关闭连接池
二、Thread类中常用的方法
1.start():启动当前线程,执行当前线程的run()
2.run():通常需要重写Thread类中的此方法,将创建的线程要执行的操作声明在此方法中
3.currentThread(): 静态方法,返回当前代码执行的线程
4.getName():获取当前线程的名字
5.setName():设置当前线程的名字
6.yield():释放当前CPU的执行权
7.join():【在线程a中调用线程b的join(),此时线程a就进入阻塞状态,直到线程b完全执行完以后,线程a才结束阻塞状态】。
8.stop():已过时。当执行此方法时,强制结束当前线程。
9.sleep(long millitime):让当前线程“睡眠”指定时间的millitime毫秒)。在指定的millitime毫秒时间内,当前线程是阻塞状态的。
10.isAlive():返回boolean,判断线程是否还活着
11.setPriority(int p):设置线程的优先级
11.getPriority():获取线程的优先级
1、停止线程
终止线程有三种方式:
(1)使用退出标志,run()执行完以后退出【抛出异常或者return】
(2)使用stop强行停止线程,不推荐,会导致当前任务执行到一半突然中断,出现不可预料的问题;而且stop和suspend以及resume一样是过期作废的方法
(3)使用interrupt中断线程
interrupt()方法不会真的停止线程,而是会记录一个标志,这个标志,可以由下面的两个方法检测到。
Thread.interrupted( ):测试当前线程是否停止,但是它具有清除线程中断状态功能,如第一次返回true,第二次调用会返回false;
Thread.isInterrupted( ):仅返回结果,不清除状态。重复调用会结果一致
基于上面的逻辑,可以根据标志来判断在run()里面状态,然后再使用interrupt()来使代码停止,停止代码可以使用抛出异常的方式。
如果在sleep里面抛出异常停止线程,会进入catch,并清除停止状态,使之变成false;
2、暂停线程与恢复线程
suspend()暂停,resume()恢复,已经被弃用,
缺点:
- 独占,使用不当很容易让公共的同步对象独占,使得其他线程无法访问。
- 不同步:线程暂停容易导致不同步。
三、线程的同步
1、解决线程安全问题
方式一:同步代码块(synchronized)
synchronized(同步监视器){
// 需要被同步的代码
}
说明:
1.操作共享数据的代码,即为需要被同步的代码。
2.共享数据:多个线程共同操作的变量。
3.同步监视器:俗称:锁。任何一个类的对象,都可以充当锁。
要求:多个线程必须要共用同一把锁。(同步监视器必须唯一)
补充:在实现Runnable接口创建多线程的方式中,我们可以考虑使用this充当同步监视器
在继承Thread类创建多线程的方式中,慎用this充当同步监视器,考虑使用当前类充当同步监视器
方式二:同步方法(synchronized)
如果操作共享数据的代码完整地声明在一个方法中,我们不妨将此方法声明同步的。
- 同步方法仍然涉及到同步监视器(锁),只是不需要我们显示的声明。
- 非静态同步方法,同步监视器是:this (对象)
静态同步方法,同步监视器是:当前类本身
同步的方式,解决了线程安全问题 — 好处
操作同步代码时,只能有一个线程参与,其他线程等待。相当于一个单线程的过程,效率低。 — 局限性
方式三:Lock锁(jdk5.0新增) 使用子类ReentrantLock;
1. 实例化reentrantLock
2. 调用锁定方法:lock()
3. 调用解锁方法:unlock()
使用的优先顺序:
Lock -> 同步代码块(已经进入方法体,分配了相应资源) -> 同步方法(在方法体之外)
【面试题】
1. synchronized 与 Lock 的异同?
相同点:解决了线程安全问题
不同点:synchronized机制在执行完相应的同步代码以后,自动的释放同步监视器
Lock需要手动的启动同步(lock()),同时结束同步也需要手动的实现(unlock())
2. 如何解决线程安全问题?有几种方式?(回答以上)
2、线程安全的单例模式之懒汉式
/**
* 使用同步机制将单例模式中的懒汉式改写为线程安全的
*/
public class BankTest {
}
class Bank{
private Bank(){}
private static Bank instance = null;
public static Bank getInstance(){
//方式一:效率稍差
//快捷键:Alt+Shift+Z
// synchronized (Bank.class) {
// if(instance == null){
// instance = new Bank();
// }
// return instance;
// }
//方式二:效率较高
if(instance == null) {
synchronized (Bank.class) {
if (instance == null) {
instance = new Bank();
}
}
}
return instance;
}
}
3、死锁问题
死锁:不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己相应的同步资源,就形成了线程的死锁。
说明:
- 出现死锁后,不会出现异常,不会出现提示,只是所有的线程都处于阻塞状态,无法继续。
- 我们使用同步时,要避免出现死锁。
解决方法:
专门的算法、原则
尽量减少同步资源的定义
尽量避免嵌套同步
四、线程的通信
涉及到的三个方法:
wait(): 一旦执行此方法,当前线程就进入阻塞状态,并释放同步监视器。
notify(): 一旦执行此方法,就会唤醒被wait的一个线程。如果有多个线程被wait,就唤醒优先级高的那个。
notifyAll(): 一旦执行此方法,就会唤醒所有被wait的线程。
说明:
1. wait(),notify(),notifyAll()三个方法必须使用在同步代码块或同步方法中。
2. wait(),notify(),notifyAll()三个方法的调用者必须是同步代码块或同步方法中的同步监视器。否则,会出现IllegalMonitorStateException异常
3. wait(),notify(),notifyAll()三个方法是定义在java.lang.Object类中。
线程通信:wait() / notify() / notifyAll() :此三个方法定义在Object类中
【调用wait方法可以让当前线程进入等待唤醒状态,该线程会处于等待唤醒状态直到另一个线程调用了object对象的notify方法或者notifyAll方法。】
面试题:sleep() 和 wait() 的异同?
1.相同点:一旦执行方法,都可以使得当前线程进入阻塞状态。
2.不同点:
- 两个方法声明的位置不同:Thread类中声明sleep(),Object类中声明wait()
- 调用的要求不同:sleep()可以在任何需要的场景下调用。wait()必须使用在同步代码块或同步方法中。
- 关于是否释放同步监视器:如果两个方法都使用在同步代码块或同步方法中,sleep()不会释放锁,wait()会释放锁。
五、线程的生命周期
新建:当一个Thread类或其子类的对象被声明并创建时,新生的线程对象处于新建状态
就绪:处于新建状态的线程被start()后,将进入线程队列等待CPU时间片,此时它已具备了运行的条件,只是没分配到CPU资源
运行:当就绪的线程被调度并获得CPU资源时,便进入运行状态,run()方法定义了线程的操作和功能
阻塞:在某种特殊情况下,被人为挂起或执行输入输出操作时,让出CPU并临时中止自己的执行,进入阻塞状态
死亡:线程完成了它的全部工作或线程被提前强制性地中止或出现异常导致结束
六、volatile 关键字
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile 修饰之后,那么就具备了两层语义:
1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
2)禁止进行指令重排序。
1、与 synchronized 对比
volatile是线程同步的轻量实现,只能修饰变量,性能高于synchronized
volatile保证可见性,不保证原子性【一旦其修饰的变量改变,其余的线程都能发现,因为会强制从公共堆栈取值】,synchronized保证原子性,间接保证可见性,因为他会将私有内存和公共内存的值同步
例如:i++操作,实际上不是原子操作,他有3步:
(1).从内存取i值
(2).计算i的值
(3).将i的新值写到内存
多个线程执行时,使用volatile,可能导致数据脏读,进而出现错误。
多线程访问volatile不会阻塞,而synchronized会
volatile是解决变量在多个线程之间的可见性,synchronized是保证多个线程之间资源的同步性。
2、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,然后使用,然后顺理成章地报错。
创作不易,关注、点赞就是对作者最大的鼓励,欢迎在下方评论留言
欢迎关注微信公众号:键指JAVA,定期分享Java知识,一起学习,共同成长。