20240115面试练习题5

1. 说一下线程池的拒绝策略有哪些?实际工作中会使用哪种拒绝策略?为什么?

1、AbortPolicy
ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。

这是线程池默认的拒绝策略,在任务不能再提交的时候,抛出异常,及时反馈程序运行状态。如果是比较关键的业务,使用此拒绝策略,这样子在系统不能承载更大的并发量的时候,能够及时的通过异常发现。

2、DiscardPolicy
ThreadPoolExecutor.DiscardPolicy:丢弃任务,但是不抛出异常。如果线程队列已满,则后续提交的任务都会被丢弃,且是静默丢弃。

使用此策略,可能会使我们无法发现系统的异常状态。是一些无关紧要的业务采用此策略。例如,博客网站统计阅读量就可采用的这种拒绝策略。

3、DiscardOldestPolicy

ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务。

此拒绝策略,是一种喜新厌旧的拒绝策略。是否要采用此种拒绝策略,还得根据实际业务是否允许丢弃老任务来认真衡量,如:发布消息。

4、CallerRunsPolicy
ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务
如果任务被拒绝了,则由调用线程(提交任务的线程)直接执行此任务


2. 如何判断线程池中的任务是否执行完成?

1、使用 isTerminated 方法判断:通过判断线程池的完成状态来实现,需要关闭线程池,一般情况下不建议使用。
2、使用 getCompletedTaskCount 方法判断:通过计划执行总任务量和已经完成总任务量,来判断线程池的任务是否已经全部执行,如果相等则判定为全部执行完成。但因为线程个体和状态都会发生改变,所以得到的是一个大致的值,可能不准确。
3、使用 CountDownLatch 判断:我们创建了一个包含 N 个任务的计数器,每个任务执行完计数器 -1,直到计数器减为 0 时,说明所有的任务都执行完了,就可以执行下一段业务的代码了。相当于一个线程安全的单次计数器,使用比较简单,且不需要关闭线程池,是比较常用的判断方法。
4、使用 CyclicBarrier 判断:CyclicBarrier 和 CountDownLatch 类似,它可以理解为一个可以重复使用的循环计数器,CyclicBarrier 可以调用 reset 方法将自己重置到初始状态。相当于一个线程安全的重复计数器,但使用较为复杂,所以日常项目中使用的较少。


3. 导致线程安全问题的因素有哪些?

1、线程是抢占执行的。
2、有的操作不是原子的。像 i++ 和 i-- 这种操作就是非原子的,它在 +1 或 -1 之前,先要查询原变量的值,并不是一次性完成的,所以就会导致线程安全问题。
3、多个线程尝试修改同一个变量(一对一修改、多对一读取、多对不同变量修改,是安全的)
4、内存可变性。在 Java 编程中内存分为两种类型:工作内存和主内存,而工作内存使用的是 CPU 寄存器实现的,而主内存是指电脑中的内存,我们知道 CPU 寄存器的操作速度是远大于内存的操作速度的。在 Java 语言中,为了提高程序的执行速度,所以在操作变量时,会将变量从主内存中复制一份到工作内存(为了效率更快),而主内存是所有线程共用的,工作内存是每个线程私有的,这就会导致一个线程已经把主内存中的公共变量修改了,而另一个线程不知道,依旧使用自己工作内存中的变量,这样就导致了问题的产生,也就导致了线程安全问题。
5、指令重排序:java的编译器在编译代码时,会针对指令进行优化,调整指令的先后顺序,保证原有逻辑不变的情况下,来提高程序的运行效率。


4. 解决线程安全问题的手段有哪些?

1、volatile 解决内存可见性和指令重排序问题
volatile 可以解决内存可见性和指令重排序的问题,代码在写入 volatile 修饰的变量的时候:
改变线程⼯作内存中volatile变量副本的值;
将改变后的副本的值从⼯作内存刷新到主内存。
代码在读取 volatile 修饰的变量的时候:
从主内存中读取volatile变量的最新值到线程的⼯作内存中;
从⼯作内存中读取volatile变量的副本。
直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存), 速度非常快, 但是可能出现数据不⼀致的情况,加上 volatile ,强制读写内存,速度虽然慢了,但是数据变得更准确了。

volatile 虽然可以解决内存可见性和指令重排序的问题,但是解决不了原子性问题,因此对于 ++ 和 --操作的线程非安全问题依然解决不了

2、使用锁(synchronized 和 lock)
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同⼀个对象 synchronized 就会 阻塞等待。

进入 synchronized 修饰的代码块, 相当于 加锁,
退出 synchronized 修饰的代码块, 相当于 解锁。

synchronized 的⼯作过程:

  • 获得互斥锁
  • 从主内存拷贝变量的最新副本到⼯作的内存
  • 执行代码
  • 将更改后的共享变量的值刷新到主内存
  • 释放互斥锁

所以 synchronized 也能保证内存可见性。

synchronized 同步块对同⼀条线程来说是可重入的,不会出现自己把自己锁死的问题。

public class ThreadSynchronized4 {
    public static void main(String[] args) {
        synchronized (ThreadSynchronized4.class) {
            System.out.println("主线程得到锁");
            synchronized (ThreadSynchronized4.class) {
                System.out.println("主线程再次得到锁");
            }
        }
    }
}

Lock 公平锁和非公平锁 :

公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。


5. synchronized 底层是如何实现的?

在JDK1.6之前都称synchronized为重量级锁
synchronized关键字在经过Javac编译之后,会在同步块的前后形成monitorenter和monitorexit两个字节码指令。

在执行monitorenter指令的时候,首先要去尝试获取对象的锁(获取对象锁的过程,其实是获取monitor对象的所有权的过程)。
如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一。
而在执行monitorexit指令时会将锁计数器减一。一旦计数器的值为零,锁随即就被释放了。
如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。

synchronized 在JDK1.6 之后做了一些优化,为了减少获得锁和释放锁来的性能开销,引入了偏向锁、轻量级锁、自旋锁、重量级锁,锁的状态根据竞争激烈的程度从低到高不断升级。

偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步。

轻量级锁
如果明显存在其它线程申请锁,那么偏向锁将很快升级为轻量级锁。

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

重量级锁

指的是原始的Synchronized的实现,重量级锁的特点:其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程。


6. 说一下 synchronized 锁升级的流程?

在 JDK 1.6 之后为了提高 synchronized 的效率,才引入了偏向锁、轻量级锁。随着锁竞争逐渐激烈,其状态会按照「无锁 ==> 偏向锁 ==> 轻量级锁 ==> 重量级锁 」这个方向逐渐升级,并且不可逆,只能进行锁升级,而无法进行锁降级。

无锁
这个可以理解为单线程很快乐的运行,没有其他的线程来和其竞争。

偏向锁
一段同步的代码,一直只被线程 A 访问,既然没有其他的线程来竞争,每次都要获取锁岂不是浪费资源?所以这种情况下线程 A 就会自动进入偏向锁的状态。
后续线程 A 再次访问同步代码时,不需要做任何的 check,直接执行(对该线程的「偏爱」),这样降低了获取锁的代价,提升了效率。

轻量级锁
一旦有第二个线程参与竞争,就会立即膨胀为轻量级锁。企图抢占的线程一开始会使用自旋的方式去尝试获取锁。如果循环几次,其他的线程释放了锁,就不需要进行用户态到内核态的切换。虽然如此,但自旋需要占用很多 CPU 的资源。JDK 1.7 之前是普通自旋,会设定一个最大的自旋次数,默认是 10 次,超过这个阈值就停止自旋。JDK 1.7 之后,引入了适应性自旋。简单来说就是:这次自旋获取到锁了,自旋的次数就会增加;这次自旋没拿到锁,自旋的次数就会减少。

重量级锁
试图抢占的线程自旋达到阈值,就会停止自旋,那么此时锁就会膨胀成重量级锁。当其膨胀成重量级锁后,其他竞争的线程进来就不会自旋了,而是直接阻塞等待,并且 Mark Word 中的内容会变成一个监视器(monitor)对象,用来统一管理排队的线程。


7. synchronized 是固定自旋次数吗?

JDK 1.7 之前是普通自旋,会设定一个最大的自旋次数,默认是 10 次,超过这个阈值就停止自旋。JDK 1.7 之后,引入了适应性自旋。简单来说就是:这次自旋获取到锁了,自旋的次数就会增加;这次自旋没拿到锁,自旋的次数就会减少。


8. synchronized 和 ReentrantLock 有什么区别?

用法不同:
synchronized 可以用来修饰普通方法、静态方法和代码块,而 ReentrantLock 只能用于代码块。

获取锁和释放锁的机制不同:
synchronized 不需要用户去手动释放锁,synchronized 代码执行完后系统会自动让线程释放对锁的占用。
ReentrantLock 则需要用户去手动释放锁,如果没有手动释放锁,就可能导致死锁现象。一般通过 lock() 和 unlock() 方法配合 try / finally 语句块来完成,使用释放更加灵活。

锁类型不同:
synchronized 为非公平锁。ReentrantLock 则即可以选公平锁也可以选非公平锁,通过构造方法 new ReentrantLock 时传入 boolean 值进行选择,为空默认 false 非公平锁,true 为公平锁。

响应中断不同:
synchronized 是不可中断类型的锁,除非加锁的代码中出现异常或正常执行完成。
ReentrantLock 则可以中断,可通过 trylock(long timeout,TimeUnit unit) 设置超时方法;或者将 lockInterruptibly() 放到代码块中,调用 interrupt 方法进行中断。

底层实现不同:
synchronized 是 JVM 层面的锁,是 Java 关键字,通过 monitor 对象来完成(monitorenter 与 monitorexit),对象只有在同步块或同步方法中才能调用 wait / notify 方法,ReentrantLock 是从 jdk1.5 以来(java.util.concurrent.locks.Lock)提供的 API 层面的锁。
synchronized 的实现涉及到锁的升级,具体为无锁、偏向锁、自旋锁、向 OS 申请重量级锁,ReentrantLock 实现则是通过利用CAS(CompareAndSwap)自旋机制保证线程操作的原子性和 volatile 保证数据可见性以实现锁的功能。


9. volatile 能保证线程安全吗?为什么?

在某些特定的情况下能。
(1)volatile能保证线程安全的情况
  要使 volatile 变量提供理想的线程安全性,必须同时满足两个条件:①对变量的写操作不依赖于当前值。②该变量没有包含在具有其他变量的不变式中。
  实际上,这两个条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。大多数编程情形都会与这两个条件的其中之一冲突(如:“若没有则添加”、“若相等则移出”的复合操作等复合操作都是与①或②相冲突的),使得 volatile 变量不能像 synchronized 那样普遍适用于实现线程安全。
(2)volatile不能保证线程安全的情况
  除了(1)中提到的能够使volatile发挥保证线程安全性的情况,其他情况下volatile并不能保证线程安全问题,因为volatile并不能保证变量操作的原子性。


10. volatile 在实际工作中,有那些使用场景?

单例模式中的“双重检查加锁模式”
jvm会把代码中没有依赖赋值的地方打乱执行顺序,由于一些规则限定,我们在单线程内观察不到打乱的现象(线程内表现为串行的语义),但是在并发程序中,从别的线程看另一个线程,操作是无序的。

public class SingletonTest {
    private volatile static SingletonTest instance = null;
    private SingletonTest() { }
    public static SingletonTest getInstance() {
        if(instance == null) {
            synchronized (SingletonTest.class){
                if(instance == null) {
                    instance = new SingletonTest();  //非原子操作
                }
            }
        }
        return instance;
    }
}

instance用了volatile修饰,由于 instance = new SingletonTest();可分解为:
memory =allocate(); //分配对象的内存空间
ctorInstance(memory); //初始化对象
instance =memory; //设置instance指向刚分配的内存地址
操作2依赖1,但是操作3不依赖2,所以有可能出现1,3,2的顺序,当出现这种顺序的时候,虽然instance不为空,但是对象也有可能没有正确初始化,会出错。用volatile则可以禁止指令重排。

状态标志
实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。
而如果使用 synchronized 块编写循环要比使用 volatile 状态标志编写麻烦很多。由于 volatile 简化了编码,并且状态标志并不依赖于程序内任何其他状态,因此此处非常适合使用 volatile。

volatile boolean shutdownRequested;
 
...
 
public void shutdown() { 
    shutdownRequested = true; 
}
 
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}

线程1执行doWork()的过程中,可能有另外的线程2调用了shutdown,所以boolean变量必须是volatile。
而如果使用 synchronized 块编写循环要比使用 volatile 状态标志编写麻烦很多。由于 volatile 简化了编码,并且状态标志并不依赖于程序内任何其他状态,因此此处非常适合使用 volatile。


你可能感兴趣的:(面试,java)