Java面试题超详细整理《多线程篇》


当多个线程访问一个对象时,如果不用进行额外的同步控制或其他的协调操作,调用这个对象的行为都可以获得正确的结果,我们就说这个对象是线程安全的 。

在每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以访问到该区域,这就是造成线程安全的潜在原因。

目前主流操作系统都是多任务的。为了保证安全,每个进程只能访问分配给自己的内存空间,而不能访问别的进程的,这是由操作系统保障的

如何保证线程安全?

方法一:使用安全类,比如 java.util.concurrent 下的类,使用原子类AtomicInteger

方法二:使用JVM提供的自动锁 synchronized。

方法三:使用JDK提供的手动锁 Lock。


synchronized关键字的作用


synchronized 它可以把任意一个非 NULL 的对象当作锁。他属于独占式的悲观锁,同时属于可重入锁。

synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

三种使用方式:

  • 修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁

  • 修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

  • 修饰代码块: 指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

总结: synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓存功能

构造方法可以使用 synchronized 关键字修饰么?

构造方法不能使用 synchronized 关键字修饰,构造方法本身就属于线程安全的,不存在同步的构造方法一说。


volatile 关键字的作用


Java 提供了 volatile 关键字来保证可见性和有序性(禁止指令重排),volatile 常用于多线程环境下的单次操作(单次读或者单次写)。

  • 对于加了 volatile关键字的成员变量,在对这个变量进行修改时,全直接将CPU高级缓存中的数据送回到主内存,对这个变量的读取也会直接从主内存中读取,从而保证了可见性

  • 在对 volatile修饰的成员变量进行读写时,会插入内存屏障,而内存屏障可以达到禁止重排序的效果,从而可以保证有序性

volatile可以和CAS 结合,来保证原子性。

讲一下 JMM(Java 内存模型)

在当前的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。要解决这个问题,就需要把变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。


说说 synchronized 关键字和 volatile 关键字的区别


synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比synchronized关键字要好 。

  • volatile 关键字只能用于变量,而synchronized 关键字可以修饰方法以及代码块 。

  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。

  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。


单例模式


双重校验锁实现对象单例(线程安全)

public class Singleton {

private volatile static Singleton uniqueInstance;

private Singleton() {

}

public static Singleton getUniqueInstance() {

//先判断对象是否已经实例过,没有实例化过才进入加锁代码

if (uniqueInstance == null) {

//类对象加锁

synchronized (Singleton.class) {

if (uniqueInstance == null) {

uniqueInstance = new Singleton();

}

}

}

return uniqueInstance;

}

}

uniqueInstance 采用 volatile 关键字修饰的原因: uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  • 为 uniqueInstance 分配内存空间

  • 初始化 uniqueInstance

  • 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

谈谈 synchronized 和 ReentrantLock 的区别


  • synchronized是一个关键字,ReentrantLock是一个类,实现了Lock接口

  • synchronized会自动加锁或释放锁,ReentrantLock需要手动加锁和释放锁

  • synchronized底层是JVM层面的锁,ReentrantLock是API层面的锁

  • synchronized是非公平锁,ReentrantLock可以选择公平锁和非公平锁

  • synchronized锁的是对象,锁信息保存在对象头中,ReentrantLock锁的线程,通过代码中int类型的state标识来标识锁的状态

  • 相比synchronized,ReentrantLock增加了一些高级功能。(等待可中断、可实现公平锁、可实现选择性通知)

Lock接口

在jdk1.5以后,增加了juc并发包且提供了Lock接口用来实现锁的功能,它除了提供了与synchroinzed关键字类似的同步功能,还提供了比synchronized更灵活api实现。可以把 Lock 看成是 synchronized 的扩展版,Lock 提供了无条件的、可轮询的(tryLock 方法)、定时的(tryLock 带参方法)、可中断的(lockInterruptibly)、可多条件队列的(newCondition 方法)锁操作。另外 Lock 的实现类基本都支持非公平锁(默认)和公平锁,synchronized 只支持非公平锁。

ReentrantLock

ReentantLock 继承接口 Lock 并实现了接口中定义的方法,他是一种可重入锁,除了能完成 synchronized 所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。

ReadWriteLock读写锁

**为了提高性能,Java 提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。读写锁分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥,这是由 jvm 自己控制的,你只要上好相应的锁即可。

读锁:如果你的代码只读数据,可以很多人同时读,但不能同时写,那就上读锁

写锁:如果你的代码修改数据,只能有一个人在写,且不能同时读取,那就上写锁

Java 中读写锁有个接口 java.util.concurrent.locks.ReadWriteLock ,也有具体的实现ReentrantReadWriteLock。**


锁分类


是否锁同步资源:乐观锁、悲观锁

乐观锁:一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。

java 中的乐观锁基本都是通过 CAS 操作实现的,CAS 是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。

悲观锁:一种悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会 block 直到拿到锁。java中的悲观锁就是synchronized;

线程是否阻塞:自旋锁

如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

线程自旋是需要消耗 CPU 的,说白了就是让 CPU在做无用功,如果一直获取不到锁,那线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。

自旋锁的优点:

自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!

线程竞争时是否排队:公平锁、非公平锁

  • 公平锁:锁的分配机制是公平的,加锁前检查是否有排队等待的线程,优先排队等待的线程,先来先得

  • 非公平锁:加锁时不考虑排队等待问题,JVM 按随机、就近原则分配锁的机制则称为不公平锁,非公平锁实际执行的效率要远远超出公平锁(5-10倍),除非程序有特殊需要,否则最常用非公平锁的分配机制。

线程中的多个流程是否可以获取同一把锁:可重入锁(递归锁)

可重入锁,也叫做递归锁,指的是同一线程外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。在 JAVA 环境下 ReentrantLock 和 synchronized 都是可重入锁。

多线程之间能不能共享同一把锁:共享锁和排他锁

  • 共享锁:共享锁则允许多个线程同时获取锁,并发访问 共享资源,如:ReadWriteLock。共享锁则是一种乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源。

  • 排他锁:每次只能有一个线程能持有锁,ReentrantLock 就是以独占方式实现的互斥锁。独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性。

锁状态:无锁、偏向锁、轻量级锁、重量级锁

锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。

**锁升级:随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,

也就是说只能从低到高升级,不会出现锁的降级)。**

  • 无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

  • 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能

  • 轻量级锁:当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

  • 重量级锁:升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

在这里插入图片描述


死锁

两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法让程序进行下去!

如何查看线程死锁:通过jstack命令进行查看,jstack中会显示发生死锁的线程

数据库中查看死锁:

查看是否有表锁:show OPEN TABLES where In_use > 0;

查询进程:show processlist;

查看正在锁的事务:SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS;

查看等待锁的事务:SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS_WAITS;


什么是守护线程?


守护 (Daemon) 线程:运行在后台,为其他前台线程服务。也可以说守护线程是 JVM 中非守护线程的 “佣人”。一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作。(GC垃圾回收线程就是一个经典的守护线程)

应用场景:为其他线程提供服务支持、需要正常且立刻关闭某个线程时

守护线程不能用于访问固有资源,比如读写操作或计算逻辑,因为它在任何时候甚至在一个操作的中间发生中断。


创建线程有哪几种方式?


继承 Thread 类,重写run方法:

public class MyThread extends Thread {

@Override

public void run() {

System.out.println(Thread.currentThread().getName() + " run()方法正在执行…");

}

}

实现 Runnable 接口,实现run方法:

public class MyRunnable implements Runnable {

@Override

public void run() {

System.out.println(Thread.currentThread().getName() + " run()方法执行中…");

}

}

实现 Callable 接口,实现call方法。通过FutureTask创建一个线程,获取到线程执行的返回值:

public class MyCallable implements Callable {

@Override

public Integer call() {

System.out.println(Thread.currentThread().getName() + " call()方法执行 中…");

return 1;

}

}

使用 Executors 工具类创建线程池,并开启线程。


Thread、Runable和Callable 三者区别?


  • Thread 是一个抽象类,只能被继承,而 Runable、Callable 是接口,需要实现接口中的方法。继承 Thread 重写run()方法,实现Runable接口需要实现run()方法,而Callable是需要实现call()方法。

  • Thread 和 Runable 没有返回值,Callable 有返回值,返回值可以被 Future 拿到。

  • 实现 Runable 接口的类不能直接调用start()方法,需要 new 一个 Thread 并发该实现类放入 Thread,再通过新建的 Thread 实例来调用start()方法。实现 Callable 接口的类需要借助 FutureTask (将该实现类放入其中),再将 FutureTask 实例放入 Thread,再通过新建的 Thread 实例来调用start()方法。获取返回值只需要借助 FutureTask 实例调用get()方法即可!

什么是 FutureTask?

FutureTask 表示一个异步运算的任务。FutureTask 里面可以传入一个Callable 的具体实现类,可以对这个异步运算的任务的结果进行等待获取、判断是否已经完成、取消任务等操作。只有当运算完成的时候结果才能取回,如果运算尚未完成 get 方法将会阻塞。一个 FutureTask 对象可以对调用了 Callable 和 Runnable 的对象进行包装,由于 FutureTask 也是Runnable 接口的实现类,所以 FutureTask 也可以放入线程池中。


线程的 run()和 start()有什么区别?


通过调用 Thread 类的 start()方法来启动一个线程, 这时此线程是处于就绪状态, 并没有运行。这时无需等待 run 方法体代码执行完毕,可以直接继续执行下面的代码。

方法 run()称为线程体,它包含了要执行的这个线程的内容,线程就进入了运行状态,开始运行 run 函数当中的代码。 run 方法运行结束, 此线程终止。然后 CPU 再调度其它线程。

run() 可以重复调用,而 start()只能调用一次。

为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用run() 方法?

如果直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。


线程的状态


线程通常有5种状态:新建、就绪、运行、阻塞和死亡状态

  • 新建(new):新创建了一个线程对象。

  • 就绪(runnable):线程对象创建后,当调用线程对象的 start()方法,该线程处于就绪状态,等待被线程调度选中,获取cpu的使用权。

  • 运行(running):可运行状态(runnable)的线程获得了cpu时间片(timeslice),执行程序代码。

  • 阻塞(block):处于运行状态中的线程由于某种原因,暂时放弃对 CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才 有机会再次被 CPU 调用以进入到运行状态。

阻塞的情况分三种:

① 等待阻塞(wait->等待对列):运行状态中的线程执行 wait()方法,JVM会把该线程放入等待队列(waittingqueue)中,使本线程进入到等待阻塞状态;

② 同步阻塞(lock->锁池):线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用),则JVM会把该线程放入锁池(lock pool)中,线程会进入同步阻塞状态;

③ 其他阻塞(sleep/join): 通过调用线程的 sleep()或 join()或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪状态。

  • 死亡(dead):线程run()、main()方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。死亡的线程不可再次复生。

在这里插入图片描述

由上图可以看出:线程创建之后它将处于 new(新建) 状态,调⽤ start() ⽅法后开始运⾏,线程这时候处于 ready(可运⾏) 状态。可运⾏状态的线程获得了 CPU 时间⽚(timeslice)后就处于 running(运⾏) 状态。


线程基本方法


  • 线程等待wait():使一个线程处于等待(阻塞)状态,并且释放所持有的对象的锁;只有等待另外线程的通知或被中断才会返回,wait 方法一般用在同步方法或同步代码块中

  • 线程睡眠sleep():使一个正在运行的线程处于睡眠状态,但不会释放当前占有的锁。是一个静态方法,调用此方法要处理InterruptedException 异常;

  • 等待其他线程终止 join():线程进入阻塞状态,马上释放cpu的执行权,但依然会保留cpu的执行资格,所以有可能cpu下次进行线程调度还会让这个线程获取到执行权继续执行

  • 线程让步yield():使当前线程让出 CPU 执行时间片,与其他线程一起重新竞争 CPU 时间片,执行后线程进入阻塞状态,例如在线程B种调用线程A的join(),那线程B会进入到阻塞队列,直到线程A结束或中断线程。

  • 线程唤醒notify():唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由 JVM 确定唤醒哪个线程,而且与优先级无关;

  • 线程全部唤醒notityAll():唤醒所有处于等待状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态;


sleep() 和 wait() 有什么区别?


两者都可以暂停线程的执行

  • 类的不同:sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。

  • 是否释放锁:sleep() 不释放锁;wait() 释放锁,并且会加入到等待队列中。

  • 是否依赖synchronized关键字:sleep不依赖synchronized关键字,wait需要依赖synchronized关键字

  • 用途不同:sleep 通常被用于休眠线程;wait 通常被用于线程间交互/通信,

  • 用法不同:sleep() 方法执行完成后,不需要被唤醒,线程会自动苏醒,或者可以使用wait(longtimeout)超时后线程会自动苏醒。wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。


什么是上下文切换?


巧妙地利用了时间片轮转的方式, CPU 给每个任务都服务一定的时间,然后把当前任务的状态保存下来,在加载下一任务的状态后,继续服务下一任务,任务的状态保存及再加载, 这段过程就叫做上下文切换。时间片轮转的方式使多个任务在同一颗 CPU 上执行变成了可能。

上下文:指某一时间点 CPU 寄存器和程序计数器的内容

几种发生上下文切换的情况:

  • 主动让出 CPU,比如调用了 sleep(), wait() 等。

  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用CPU导致其他线程或者进程饿死。

  • 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞,被终止或结束运行


线程之间如何进行通信


  • 通过共享内存或基于网络通信

  • 如果是基于共享内存进行通信,则需要考虑并发问题,什么时候阻塞,什么时候唤醒

  • 想Java中的wait()、notify()就是阻塞唤醒

  • 通过网络就比较简单,通过网络连接将数据发送给对方,当然也要考虑到并发问题,处理方式就是加锁等方式。


最后

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!

如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
阻塞唤醒

  • 通过网络就比较简单,通过网络连接将数据发送给对方,当然也要考虑到并发问题,处理方式就是加锁等方式。

最后

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

[外链图片转存中…(img-4qP8avdU-1714856056985)]

[外链图片转存中…(img-9cSAlIDP-1714856056985)]

[外链图片转存中…(img-VQAoqnIa-1714856056986)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!

如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

  • 14
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值