后端常问面经之并发

volatile 关键字

volatile关键字是如何保证内存可见性的?底层是怎么实现的?

"观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令"lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能: 1.它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成; 2.它会强制将对缓存的修改提作立即写入主存: 3.如果是写操作,它会导致其他CPU中对应的缓存行无效。

所以可见性和禁止指令重排序如下: 可见性:volatile的功能就是被修饰的变量在被修改后可以立即同步到主内存,被修饰的变量在每次是用之前都从主内存刷新。 禁止指令重排序; volatile是通过内存屏障来禁止指令重排序JMM内存屏障的策略 在每个 volatile 写操作的前面插入一个 StoreStore 屏障,A StoreStore B,B的写操作不会重排序到A之前。 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障,后面的读操作不会重排序到写操作之前。 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障,后面的读操作不会重排序到读操作之前。 在每个 volatile 读操作的后面插入一个 LoadStore 屏障,后面的写操作不会重排序到读操作之前。

2. 如何禁止指令重排序?

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

复制代码
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(); 这段代码其实是分为三步执行:

  1. uniqueInstance 分配内存空间

  2. 初始化 uniqueInstance

  3. uniqueInstance 指向分配的内存地址

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

3.volatile 可以保证原子性么?

volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。

如何保证线程安全?

  • 使用线程安全的集合类,如ConcurrentHashMap、CopyOnWriteArrayList等。

  • 使用同步代码块(synchronized)或同步方法来保护共享资源,确保在同一时刻只有一个线程访问。

  • 使用Lock接口及其实现类(如ReentrantLock)来进行线程同步

  • 使用ThreadLocal来保证每个线程都有自己独立的变量副本

乐观锁和悲观锁

什么是悲观锁?

Java 中synchronizedReentrantLock等独占锁就是悲观锁思想的实现,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。

什么是乐观锁?

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改 的时候去验证对应的资源 (也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。

synchronized 关键字

synchronized 是什么?有什么用?

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

synchronized关键字的底层原理

synchronized 底层使用的JVM级别中的Monitor 来决定当前线程是否获得了锁,如果某一个线程获得了锁,在没有释放锁之前,其他线程是不能或得到锁的。synchronized 属于悲观锁。

synchronized 因为需要依赖于JVM级别的Monitor ,相对性能也比较低,因为涉及到了用户态和内核态的切换、进程的上下文切换,成本高,性能低。

介绍Monitor

monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因

monitor内部维护了三个变量

  • WaitSet:保存处于Waiting状态的线程

  • EntryList:保存处于Blocked状态的线程

  • Owner:持有锁的线程

只有一个线程获取到的标志就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner

在上锁的过程中,如果有其他线程也来抢锁,则进入EntryList 进行阻塞,当获得锁的线程执行完了,释放了锁,就会唤醒EntryList 中等待的线程竞争锁,竞争的时候是非公平的。

线程是怎么确定拿到锁的?

线程确定拿到锁的过程:是通过检查锁的状态并尝试获取锁来实现的。在JVM中,锁信息具体是存放在Java对象头中的。

当一个线程尝试进入synchronized代码块或方法时,JVM会检查对应对象的锁状态。如果对象的锁未被其他线程持有,即锁状态为可获取,那么该线程将成功获取锁并进入临界区执行代码。

synchronized 的锁升级

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

重量级锁:底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。

轻量级锁:线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性

偏向锁:一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令

一旦锁发生了竞争,都会升级为重量级锁

如何使用 synchronized?

synchronized 关键字的使用方式主要有下面 3 种:

  1. 修饰实例方法(锁实例方法)

  2. 修饰静态方法(锁当前类)

  3. 修饰代码块(锁对象或者类)

synchronized 和 volatile 有什么区别?

volatile关键字主要解决变量在多个线程之间的可见性 和和禁止指令重排序 ,synchronized关键字解决的是多个线程之间访问资源的同步性

ReentrantLock

ReentrantLock 是什么?

  • ReentrantLock实现了Lock接口 ,是一个可重入且独占式的锁,和synchronized关键字类似。

  • 不过,ReentrantLock更灵活、更强大、增加了等待可中断中断、公平锁和非公平锁、轮询、超时等高级功能。

复制代码
public class ReentrantLock implements Lock, java.io.Serializable {}
​

ReentrantLock的底层原理

主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似

构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高。

CAS和AQS

CAS的全称是: Compare And Swap(比较再交换);它体现的一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性。

  • CAS使用到的地方很多:AQS框架、AtomicXXX类

  • 在操作共享变量的时候使用的自旋锁,效率上更高一些

  • CAS的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现

AQS的话,其实就一个jdk提供的类AbstractQueuedSynchronizer,是阻塞式锁(和相关的同步器工具)的框架。

内部有一个属性 state 属性来表示资源的状态,默认state等于0,表示没有获取锁,state等于1的时候才标明获取到了锁。通过cas 机制设置 state 状态

在它的内部还提供了基于 FIFO 的等待队列,是一个双向列表,其中

  • tail 指向队列最后一个元素

  • head 指向队列中最前面的一个元素

其中我们刚刚聊的ReentrantLock底层的实现就是一个AQS。

ReentrantLock 与 synchronized对比

第一,语法层面

  • synchronized 是关键字,用 c++ 语言实现,退出同步代码块锁会自动释放

  • Lock 是接口,用 java 语言实现,需要手动调用 unlock 方法释放锁

第二,功能层面

  • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能

  • Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断,同时Lock 可以实现不同的场景,如 ReentrantLock, ReentrantReadWriteLock

第三,性能层面

  • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖

  • 在竞争激烈时,Lock 的实现通常会提供更好的性能

公平锁和非公平锁

什么是公平锁和非公平锁?

  • 公平锁: 指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点在于各个线程公平平等,每个线程等待一段时间后,都有执行的机会,而它的缺点就在于整体执行速度更慢,吞吐量更小。

  • 非公平锁: 多个线程加锁时直接尝试获取锁,能抢到锁到直接占有锁,抢不到才会到等待队列的队尾等待。非公平锁的优势就在于整体执行速度更快,吞吐量更大,但同时也可能产生线程饥饿问题,也就是说如果一直有线程插队,那么在等待队列中的线程可能长时间得不到运行。

非公平锁吞吐量为什么比公平锁大?

  • 公平锁执行流程 :获取锁时,先将线程自己添加到等待队列的队尾并休眠,当某线程用完锁之后,会去唤醒等待队列中队首的线程尝试去获取锁,锁的使用顺序也就是队列中的先后顺序,在整个过程中,线程会从运行状态切换到休眠状态,再从休眠状态恢复成运行状态,但线程每次休眠和恢复(上下文切换)都需要从用户态转换成内核态(因为用户线程运行是在用户态,休眠调用的Thread.sleep()是系统调用),而这个状态的转换是比较慢的,所以公平锁的执行速度会比较慢。

  • 非公平锁执行流程 :当线程获取锁时,会先通过 CAS 尝试获取锁,如果获取成功就直接拥有锁,如果获取锁失败才会进入等待队列,等待下次尝试获取锁。这样做的好处是,获取锁不用遵循先到先得的规则,从而避免了线程休眠和恢复的操作,这样就加速了程序的执行效率。

reentrantlock如何实现公平锁和非公平锁?

公平锁与非公平锁的 lock() 方法唯一的区别就在于公平锁在获取锁时多了一个限制条件:hasQueuedPredecessors() 为 false,这个方法就是判断在等待队列中是否已经有线程在排队了。这也就是公平锁和非公平锁的核心区别,如果是公平锁,那么一旦已经有线程在排队了,当前线程就不再尝试获取锁;对于非公平锁而言,无论是否已经有线程在排队,都会尝试获取一下锁,获取不到的话,再去排队。

ThreadLocal

ThreadLocal 有什么用?

ThreadLocal类位于java.lang 包下,是JDK提供的一个类。TreadLocal类存放的是每个线程的私有数据,实现了线程之间的数据隔离,每个访问TreadLocal变量的线程都会有这个变量的本地副本。

ThreadLocal 原理了解吗?

  1. 每个Thread线程内部都有一个ThreadLocalMap,Map里面存储的是ThreadLocal对象作为key,线程的变量副本作为值。

  2. 当我们调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中

get 方法的主要流程为:

  • 先获取到当前线程的引用

  • 获取当前线程内部的 ThreadLocalMap

  • 如果 map 存在,则获取当前 ThreadLocal 对应的 value 值

  • 如果 map 不存在或者找不到 value 值,则调用 setInitialValue() 进行初始化

其中每个 Thread 的 ThreadLocalMap 以 threadLocal 作为 key,保存自己线程的 value 副本,也就是保存在每个线程中,并没有保存在 ThreadLocal 对象中。

set 方法的作用是把我们想要存储的 value 给保存进去。set 方法的流程主要是:

  • 先获取到当前线程的引用

  • 利用这个引用来获取到 ThreadLocalMap

  • 如果 map 为空,则去创建一个 ThreadLocalMap

  • 如果 map 不为空,就利用 ThreadLocalMap 的 set 方法将 value 添加到 map 中

ThreadLocal会导致内存溢出

是应为ThreadLocalMap 中的 key 被设计为弱引用,在内存不太够用的时候,只有key可以得到内存释放,而value不会,因为value是一个强引用。

在使用ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收,建议主动的remove 释放 key,这样就能避免内存溢出。

ThreadLocal的使用

数据的跨层使用(controller,service,dao)

每个线程保存类似全局变量的信息(例如拦截器中的用户信息),可以让不同层的方法直接使用,但不想被多线程共享,此时就可以用ThreadLocal保存业务的信息(例如用户id)。

Spring使用ThreadLocal解决线程安全问题

**一般情况下,只有无状态的Bean才能在多线程环境下共享。对于有些有状态的Bean,将Bean中一些非线程安全的变量放在ThreadLocal中,使他们成为线程安全的Bean,就可以在多线程环境下共享了。**并且在同一次请求响应的调用线程中,所有对象访问的同一个ThreadLocal对象都是当前线程所绑定的。

示例:

TopicDao:非线程安全,由于①处的conn是成员变量,因为addTopic()方法是非线程安全的,必须在使用时创建一个新TopicDao实例(非singleton)。下面使用ThreadLocal对conn这个非线程安全的"状态"进行改造:

创建线程的方式

方式一:继承Thread类并重写run()方法。

  • 优点: 编写简单,如果需要访问当前线程,无需使用Thread.currentThread ()方法,直接使用this,即可获得当前线程

  • 缺点:因为线程类已经继承了Thread类,所以不能再继承其他的父类

复制代码
public class CreatingThread01 extends Thread {
    @Override
    public void run() {
        System.out.println(getName() + " is running");
    }
​
    public static void main(String[] args) {
        new CreatingThread01().start();
        new CreatingThread01().start();
        new CreatingThread01().start();
        new CreatingThread01().start();
    }
}

方式二:实现Runnable接口并实现run()方法,然后将实现了Runnable接口的类传递给Thread类。

采用实现Runnable接口方式:

  • 优点:线程类只是实现了Runable接口,还可以继承其他的类。在这种方式下,可以多个线程共享同一个目标对象,所以非常适合多个相同线程来处理同一份资源的情况。

  • 缺点:编程稍微复杂,如果需要访问当前线程,必须使用Thread.currentThread()方法。

复制代码
public class CreatingThread02 implements Runnable {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " is running");
    }
​
    public static void main(String[] args) {
        new Thread(new CreatingThread02()).start();
        new Thread(new CreatingThread02()).start();
        new Thread(new CreatingThread02()).start();
        new Thread(new CreatingThread02()).start();
    }
}

方式三:使用Callable和Future接口通过Executor框架创建线程。

  • 优点:1. 线程类只是实现了Runable接口,还可以继承其他的类。 2. 在这种方式下,可以多个线程共享同一个目标对象,所以非常适合多个相同线程来处理同一份资源的情况。 3. 允许线程有返回值

  • 缺点:编程稍微复杂,如果需要访问当前线程,必须调用Thread.currentThread()方法。

复制代码
public class CreatingThread03 implements Callable<Long> {
    @Override
    public Long call() throws Exception {
        Thread.sleep(2000);
        System.out.println(Thread.currentThread().getId() + " is running");
        return Thread.currentThread().getId();
    }
​
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        FutureTask<Long> task = new FutureTask<>(new CreatingThread03());
        new Thread(task).start();
        System.out.println("等待完成任务");
        Long result = task.get();
        System.out.println("任务结果:" + result);
    }
}

线程池

什么是线程池?

线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完后线程并不会立即被销毁,而是等待下一个任务。

如何创建线程池?

方式一:

在jdk中默认提供了4中方式创建线程池

第一个是:newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回 收空闲线程,若无可回收,则新建线程。

第二个是:newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列 中等待。

第三个是:newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。

第四个是:newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

方式二:通过**ThreadPoolExecutor**构造函数来创建(推荐)。

在线程池中一共有7个核心参数:

  • corePoolSize:线程池核心线程数量。默认情况下,线程池中线程的数量如果 <= corePoolSize,那么即使这些线程处于空闲状态,那也不会被销毁。

  • maximumPoolSize:线程池中最多可容纳的线程数量。当一个新任务交给线程池,如果此时线程池中有空闲的线程,就会直接执行,如果没有空闲的线程且当前线程池的线程数量小于corePoolSize,就会创建新的线程来执行任务,否则就会将该任务加入到阻塞队列中,如果阻塞队列满了,就会创建一个新线程,从阻塞队列头部取出一个任务来执行,并将新任务加入到阻塞队列末尾。如果当前线程池中线程的数量等于maximumPoolSize,就不会创建新线程,就会去执行拒绝策略。

  • keepAliveTime:当线程池中线程的数量大于corePoolSize,并且某个线程的空闲时间超过了keepAliveTime,那么这个线程就会被销毁。

  • unit:就是keepAliveTime时间的单位。

  • workQueue:工作队列。当没有空闲的线程执行新任务时,该任务就会被放入工作队列中,等待执行。

  • threadFactory:线程工厂。可以用来给线程取名字等等

  • handler:拒绝策略。当一个新任务交给线程池,如果此时线程池中有空闲的线程,就会直接执行,如果没有空闲的线程,就会将该任务加入到阻塞队列中,如果阻塞队列满了,就会创建一个新线程,从阻塞队列头部取出一个任务来执行,并将新任务加入到阻塞队列末尾。如果当前线程池中线程的数量等于maximumPoolSize,就不会创建新线程,就会去执行拒绝策略。

如何自定义线程池?

要自定义线程池,可以使用 ThreadPoolExecutor 类。以下是创建自定义线程池的步骤:

  1. 创建一个 ThreadPoolExecutor 对象,可以通过构造方法来指定核心线程数、最大线程数、线程空闲时间、任务队列等参数。

  2. 可以通过调用 execute(Runnable command) 方法向线程池提交任务。

  3. 可以通过调用 shutdown() 方法关闭线程池。

示例代码如下:

复制代码
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
    5, // 核心线程数
    10, // 最大线程数
    60, // 线程空闲时间
    TimeUnit.SECONDS, // 时间单位
    new ArrayBlockingQueue<>(100) // 任务队列
);
​
threadPool.execute(() -> {
    // 执行任务逻辑
});
​
threadPool.shutdown();

线程池处理任务的流程了解吗?

线程池分为核心线程池,线程池的最大容量,还有等待任务的队列,提交一个任务,如果核心线程没有满,就创建一个线程,如果满了,就是会加入等待队列,如果等待队列满了,就会增加线程,如果达到最大线程数量,如果都达到最大线程数量,就会按照一些丢弃的策略进行处理。

为什么核心线程满了之后是先加入阻塞队列而不是直接加到总线程?

  • 线程池创建线程需要获取mainlock这个全局锁,会影响并发效率,所以使用阻塞队列把第一步创建核心线程与第三步创建最大线程隔离开来,起一个缓冲的作用。

  • 引入阻塞队列,是为了在执行execute()方法时,尽可能的避免获取全局锁。

核心线程数一般设置为多少?

假设机器有N个CPU:

  • 如果是CPU密集型 应用,则线程池大小设置为N+1,线程的应用场景:主要是复杂算法

  • 如果是IO密集型 应用,则线程池大小设置为2N+1,线程的应用场景:主要是:数据库数据的交互,文件上传下载,网络数据传输等等

对于同时有计算工作和IO工作的任务,应该考虑使用两个线程池,一个处理计算任务,一个处理IO任务,分别对两个线程池按照计算密集型和IO密集型来设置线程数。

IO密集型的线程数为什么一般设置为2N+1?

在IO密集型任务中,线程通常会因为IO操作而阻塞,此时可以让其他线程继续执行,充分利用CPU资源。设置为2N+1可以保证在有多个线程阻塞时,仍有足够的线程可以继续执行。

为什么不建议使用Executors创建线程池呢

如果使用Executors创建线程池的话,它允许的请求队列默认长度是Integer.MAX_VALUE,这样的话,有可能导致堆积大量的请求,从而导致OOM(内存溢出)。

相关推荐
minDuck2 分钟前
ruoyi-vue集成tianai-captcha验证码
java·前端·vue.js
白子寰6 分钟前
【C++打怪之路Lv14】- “多态“篇
开发语言·c++
王俊山IT18 分钟前
C++学习笔记----10、模块、头文件及各种主题(一)---- 模块(5)
开发语言·c++·笔记·学习
为将者,自当识天晓地。20 分钟前
c++多线程
java·开发语言
小政爱学习!22 分钟前
封装axios、环境变量、api解耦、解决跨域、全局组件注入
开发语言·前端·javascript
daqinzl28 分钟前
java获取机器ip、mac
java·mac·ip
k093337 分钟前
sourceTree回滚版本到某次提交
开发语言·前端·javascript
激流丶44 分钟前
【Kafka 实战】如何解决Kafka Topic数量过多带来的性能问题?
java·大数据·kafka·topic
神奇夜光杯1 小时前
Python酷库之旅-第三方库Pandas(202)
开发语言·人工智能·python·excel·pandas·标准库及第三方库·学习与成长
Themberfue1 小时前
Java多线程详解⑤(全程干货!!!)线程安全问题 || 锁 || synchronized
java·开发语言·线程·多线程·synchronized·