您好,欢迎来到品趣旅游知识分享网。
搜索
您的当前位置:首页【2021Java后端面试题整理】Java多线程面试题+面经答案

【2021Java后端面试题整理】Java多线程面试题+面经答案

来源:品趣旅游知识分享网

目录

多线程

Java多线程

1.什么是线程和进程

什么是进程?

进程就是系统执行程序的一次过程,是系统运行程序的基本单位,因此进程是动态的。一次程序的运行是一个进程从创建、运行到消亡的过程。

什么是线程?

线程是比进程更小的执行单位,通常在一个进程执行的过程中会产生许多的进程,他们可能会紧密相关。从JVM的角度来看,不同的线程共享堆和元空间,拥有自己的程序计数器、虚拟机栈和本地方法栈。

2.请简要描述线程与进程的关系,区别及优缺点?

在一个进程运行的过程中,会产生一系列的线程,它是比进程更小的执行单位。进程和进程之间是相互的,线程却不一定,同一进程中的线程可能会相互影响,同一进程中的所有线程都共享堆和元空间(方法区),都有自己私有的程序计数器,虚拟机栈,本地方法栈。线程相对于进程而言,开销更小,但是缺点就是不易于管理和保护

3.并发与并行的区别

  • 并发: 同一时间段,多个任务都在执行 (单位时间内不一定同时执行);
  • 并行: 单位时间内,多个任务同时执行。

并发:

  • 多个事件在同一时间段内发生
  • 同一个CPU执行多个任务,按细分的时间片交替执行
  • 并发的多个任务会相互抢占资源

并行:

  • 多个事件在同一时间点上发生
  • 在多个CPU上同时处理多个任务
  • 并行的多个任务不会相互抢占资源

4.为什么要使用多线程呢?

先从总体上来说:

  • 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
  • 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

再深入到计算机底层来探讨:

  • 单核时代: 在单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率。举个例子:当只有一个线程的时候会导致 CPU 计算时,IO 设备空闲;进行 IO 操作时,CPU 空闲。我们可以简单地说这两者的利用率目前都是 50%左右。但是当有两个线程的时候就不一样了,当一个线程执行 CPU 计算时,另外一个线程可以进行 IO 操作,这样两个的利用率就可以在理想情况下达到 100%了。
  • 多核时代: 多核时代多线程主要是为了提高 CPU 利用率。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,CPU 只会一个 CPU 核心被利用到,而创建多个线程就可以让多个 CPU 核心被利用到,这样就提高了 CPU 的利用率。

5.使用多线程可能带来什么问题?

并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、上下文切换、死锁还有受限于硬件和软件的资源闲置问题。

5.5线程的生命周期

new,runnable,blocked,waiting,timed waiting,terminated

**六大状态:**NEW、RUNNABLE、BLOCKED、WAITIING、TIME_WAITING、TERMINAED

1,当进入synchronized同步代码块或同步方法时,且没有获取到锁,线程就进入了blocked状态,直到锁被释放,重新进入runnable状态

2,当线程调用wait()或者join时,线程都会进入到waiting状态,当调用notify或notifyAll时,或者join的线程执行结束后,会进入runnable状态

3,当线程调用sleep(time),或者wait(time)时,进入timed waiting状态,

当休眠时间结束后,或者调用notify或notifyAll时会重新runnable状态。

4,程序执行结束,线程进入terminated状态

blocked,waiting,timed waiting 我们都称为阻塞状态

上述的就绪状态和运行状态,都表现为runnable状态

6.什么是上下文切换?

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。

概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

7.什么是线程死锁?如何避免死锁?

线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

产生死锁必须具备以下四个条件:

如何避免线程死锁?

为了避免死锁,我们只要破坏产生死锁的四个条件中的其中一个就可以了。现在我们来挨个分析一下:

  1. 破坏互斥条件 :这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
  2. 破坏请求与保持条件 :一次性申请所有的资源。
  3. 破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  4. 破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

8.说说 sleep() 方法和 wait() 方法区别和共同点?

  • sleep()方法是让线程进入休眠,wait()让线程进入等待,两者都可以暂停线程的执行,但这之间最重要的却别就是前者没有释放锁,而后者释放了锁
  • wait()通常被用于线程间交互通信,sleep()通常被用于暂停执行。
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout)超时后线程会自动苏醒。

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

这是另一个非常经典的 java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!

new 一个 Thread,线程进入了新建状态;调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结: 调用 start() 方法方可开启一个新线程并使线程进入就绪状态,而 run() 方法只是 thread 的一个普通方法调用,如果在主线程中调用,还是在主线程里执行。

10.synchronized 关键字

1.说一说自己对于 synchronized 关键字的了解

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

另外,在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

2.说说自己是怎么使用 synchronized 关键字,在项目中用到了吗
3.synchronized关键字最主要的三种使用方式
  • 修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
  • 修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管ne
  • w了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁
  • 修饰代码块: 指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

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

下面我以一个常见的面试题为例讲解一下 synchronized 关键字的具体使用。

面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”

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

public class Singleton {
   

    private volatile static Singleton uniqueInstance;

    private Singleton() {
   
    }

    public synchronized static Singleton getUniqueInstance() {
   
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
   
            //类对象加锁
            synchronized (Singleton.class) {
   
                if (uniqueInstance == null) {
   
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

另外,需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址

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

使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

4.讲一下 synchronized 关键字的底层原理

synchronized 关键字底层原理属于 JVM 层面。

① synchronized 同步语句块的情况

public class SynchronizedDemo {
   
    public void method() {
   
        synchronized (this) {
   
            System.out.println("synchronized 代码块");
        }
    }
}

从上面我们可以看出:

synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

synchronized保证原子性

ObjectMonitor

jdk6以后,加锁方式变成了CAS加锁,如果锁计数器变成1,则加锁成功,owner将赋值为线程1

synchronized内存屏障保证可见性和有序性

按可见性划分有Load屏障和Store屏障

Load屏障作用是执行refresh处理器缓存的操作,也就是加载别的处理器更新过的变量,保证自己看的是最新的数据.

Store屏障的作用是执行flush处理器缓存的操作,把自己的当前处理器更新的值都刷到高速缓存或者主内存中

在monitorexit指令之后,会有一个Store屏障,让线程把自己在同步代码块里修改的变量的值都执行 fush处理器缓存的操作,刷到高速缓存(或者主内存〉里去,然后在monitorenter指令之后会加一个 Load屏障,执行refresh处理器缓存的操作,把别的处理器修改过的最新值加戟到自己高速缓存里来

按照有序性保障来划分的话,还可分为Acquire屏障和Release屏障。
在monitorenter指令之后,Load屏障之后,会加一个 Acquire屏障,这个屏障的作用是禁止读操作和读写操作之间发生指令重排序。在monitorexit指令之前,会加一个Release屏障,这个屏障的作用是禁止写操作和读写操作之间发生重排序。

synchronized(A) {->monitorenter

Load内存屏障

Acquire内存屏障

读写操作 ->内部还是可以指令重排,但是和外面的代码不会指令重排

Release内存屏障

}

->monitorexit

Store内存屏障

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TamxuQqH-16171602483)(D:\学习笔记\面试\pic\synchronized底层2.png)]

② synchronized 修饰方法的的情况

public class SynchronizedDemo2 {
   
    public synchronized void method() {
   
        System.out.println("synchronized 方法");
    }
}
5.说说 JDK1.6 之后的synchronized 关键字底层做了哪些优化,可以详细介绍一下这些优化吗

JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

(1)锁消除

JIT编译器对synchronized加锁的优化,JIT编译器会通过逃逸分析技术,分析是不是只可能被一个线程加锁,有没有其他线程来竞争加锁,这时候编译器不会加入monitorenter和monitorexit的指令

就是如果只有一个线程竞争锁,就可以消除这个锁

(2)锁粗化

JIT编译器如果发现代码有多次加锁释放锁是连续的,会合并成一个锁,避免多次加锁释放锁

(3)偏向锁

这个意思就是说, monitorenter和monitorexit是要使用CAS操作加锁和释放锁的,开销大,因此如果发现大概率只有一个线程会主要竞争一个锁,那么会给这个锁维护一个偏好(Bias),后面他加锁和释放锁,基于Bias来执行,不需要通过CAS

但是如果有偏好之外的线程来竞争锁,要收回之前分配好的Bias偏好

(4)轻量级锁

如果偏向锁没有实现成功实现,就是因为不同线程竞争太过频繁,会尝试使用轻量级锁,将对象头的MarkWord里面有一个轻量锁指针,尝试将指针指向自己看看是不是自己加的锁

如果是自己家的锁,就执行代码

如果不是自己加的锁,那就加锁失败,说明别人家了锁,这时候会膨胀为为重量级锁,CAS

(5)自适应性锁

如果各个线程持有锁的时间很短,那么就会产生频繁的上下文切换,开销过大.这时候就需要自旋锁,不断获取锁

6.谈谈 synchronized和ReentrantLock 的区别

① 两者都是可重入锁

两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

② synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

③ ReentrantLock 比 synchronized 增加了一些高级功能

相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)

  • ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
  • ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。
  • synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制,ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition() 方法。Condition是JDK1.5之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是Condition接口默认提供的。而synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。

如果你想使用上述功能,那么选择ReentrantLock是一个不错的选择。

④ 性能已不是选择标准

12.volatile关键字

1.什么是volatile

volatile是JVM提供的轻量级的同步机制,有以下三个重要特性:保证可见性,不保证原子性,禁止指令重排

可见性是Load和Store的内存屏障

有序性

对于volatile修改变量的读写操作,都会加入内存屏障
每个volatile 写操作前面,加storeStore屏障,禁止上面的普通写和他重排,每个volatile写操作后面,加StoreLoad屏障,禁止跟下面的 volatile读/写重排
每个volatile读操作后面,加LoadLoad屏障,禁止下面的普通读和voaltile读重排;每个volatile读操作后面,加LoadStore屑障,禁止下面的普通写和volatile读重排

2.请你谈谈JMM(java内存模型)

JMM是指Java内存模型,不是Java内存布局,不是所谓的栈、堆、方法区。是一组规则或者规范

每个Java线程都有自己的工作内存。操作数据,首先从主内存中读,得到一份拷贝,操作完毕后再写回到主内存。

JMM可能带来可见性原子性有序性问题。所谓可见性,就是某个线程对主内存内容的更改,应该立刻通知到其它线程。原子性是指一个操作是不可分割的,不能执行到一半,就不执行了。所谓有序性,就是指令是有序的,不会被重排。

可见性

线程将主内存中的数据拷贝到自己的内存中,然后将数据进行修改,需要通知其他所有线程,这就叫可见性

class MyData{
   
    int number=0;
    //volatile int number=0;

    AtomicInteger atomicInteger=new AtomicInteger();
    public void setTo60(){
   
        this.number=60;
    }

    //此时number前面已经加了volatile,但是不保证原子性
    public void addPlusPlus(){
   
        number++;
    }

    public void addAtomic(){
   
        atomicInteger.getAndIncrement();
    }
}

//volatile可以保证可见性,及时通知其它线程主物理内存的值已被修改
private static void volatileVisibilityDemo() {
   
    System.out.println("可见性测试");
    MyData myData=new MyData();//资源类
    //启动一个线程操作共享数据
    new Thread(()->{
   
        System.out.println(Thread.currentThread().getName()+"\t come in");
        try {
   TimeUnit.SECONDS.sleep(3);myData.setTo60();
        System.out.println(Thread.currentThread().getName()+"\t update number value: "+myData.number);}catch (InterruptedException e){
   e.printStackTrace();}
    },"AAA").start();
    while (myData.number==0){
   
     //main线程持有共享数据的拷贝,一直为0
    }
    System.out.println(Thread.

因篇幅问题不能全部显示,请点此查看更多更全内容

Copyright © 2019- pqdy.cn 版权所有 赣ICP备2024042791号-6

违法及侵权请联系:TEL:199 1889 7713 E-MAIL:2724546146@qq.com

本站由北京市万商天勤律师事务所王兴未律师提供法律服务