Java并发面试题第二弹

152 阅读13分钟

「这是我参与2022首次更文挑战的第15天,活动详情查看:2022首次更文挑战」。

大家好,我是大彬~

今天给大家分享Java并发高频面试题(第二弹),助力春招上岸!

文章目录如下:

  • volatile底层原理
  • synchronized的用法有哪些?
  • synchronized的作用有哪些?
  • synchronized 底层实现原理?
  • volatile和synchronized的区别?
  • ReentrantLock和synchronized区别?
  • wait()和sleep()的异同点?
  • Runnable和Callable有什么区别?
  • 守护线程是什么?
  • 线程间通信方式
  • ThreadLocal
    • ThreadLocal原理
    • ThreadLocal内存泄漏的原因?
    • ThreadLocal使用场景有哪些?
  • AQS原理
  • ReentrantLock 是如何实现可重入性的?
  • 锁的分类
    • 公平锁与非公平锁
    • 共享式与独占式锁
    • 悲观锁与乐观锁
  • 乐观锁有什么问题?
  • 什么是CAS?

volatile底层原理

volatile是轻量级的同步机制,volatile保证变量对所有线程的可见性,不保证原子性。

  1. 当对volatile变量进行写操作的时候,JVM会向处理器发送一条LOCK前缀的指令,将该变量所在缓存行的数据写回系统内存。
  2. 由于缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存中。

来看看缓存一致性协议是什么。

缓存一致性协议:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,就会从内存重新读取。

volatile关键字的两个作用:

  1. 保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  2. 禁止进行指令重排序

指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。Java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止处理器重排序。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。对一个volatile字段进行写操作,Java内存模型将在写操作后插入一个写屏障指令,这个指令会把之前的写入值都刷新到内存。

synchronized的用法有哪些?

  1. 修饰普通方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁
  2. 修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁,synchronized关键字加到static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁
  3. 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁

synchronized的作用有哪些?

原子性:确保线程互斥的访问同步代码;

可见性:保证共享变量的修改能够及时可见;

有序性:有效解决重排序问题。

synchronized 底层实现原理?

synchronized 同步代码块的实现是通过 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor的持有权(monitor对象存在于每个Java对象的对象头中, synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因)。

其内部包含一个计数器,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0 ,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

volatile和synchronized的区别?

  1. volatile只能使用在变量上;而synchronized可以在类,变量,方法和代码块上。
  2. volatile至保证可见性;synchronized保证原子性与可见性。
  3. volatile禁用指令重排序;synchronized不会。
  4. volatile不会造成阻塞;synchronized会。

ReentrantLock和synchronized区别?

  1. 使用synchronized关键字实现同步,线程执行完同步代码块会自动释放锁,而ReentrantLock需要手动释放锁。
  2. synchronized是非公平锁,ReentrantLock可以设置为公平锁。
  3. ReentrantLock上等待获取锁的线程是可中断的,线程可以放弃等待锁。而synchonized会无限期等待下去。
  4. ReentrantLock 可以设置超时获取锁。在指定的截止时间之前获取锁,如果截止时间到了还没有获取到锁,则返回。
  5. ReentrantLock 的 tryLock() 方法可以尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回true,否则返回false。

wait()和sleep()的异同点?

相同点

  1. 使当前线程暂停运行,把机会交给其他线程
  2. 任何线程在等待期间被中断都会抛出InterruptedException

不同点

  1. wait()是Object超类中的方法;而sleep()是线程Thread类中的方法
  2. 对锁的持有不同,wait()会释放锁,而sleep()并不释放锁
  3. 唤醒方法不完全相同,wait()依靠notify或者notifyAll 、中断、达到指定时间来唤醒;而sleep()到达指定时间被唤醒
  4. 调用wait()需要先获取对象的锁,而Thread.sleep()不用

Runnable和Callable有什么区别?

  • Callable接口方法是call(),Runnable的方法是run()
  • Callable接口call方法有返回值,支持泛型,Runnable接口run方法无返回值。
  • Callable接口call()方法允许抛出异常;而Runnable接口run()方法不能继续上抛异常。

守护线程是什么?

守护线程是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。在 Java 中垃圾回收线程就是特殊的守护线程。

线程间通信方式

volatile

volatile 使用共享内存实现线程间相互通信。多个线程同时监听一个变量,当这个变量被某一个线程修改的时候,其他线程可以感知到这个变化。

wait和 notify

wait/notify为Object对象的方法,调用wait/notify需要先获得对象的锁。对象调用wait()之后线程释放锁,将线程放到对象的等待队列,当通知线程调用此对象的notify()方法后,等待线程并不会立即从wait()返回,需要等待通知线程释放锁(通知线程执行完同步代码块),等待队列里的线程获取锁,获取锁成功才能从wait()方法返回,即从wait()方法返回前提是线程获得锁。

join

当在一个线程调用另一个线程的join()方法时,当前线程阻塞等待被调用join方法的线程执行完毕才能继续执行。join()是基于等待通知机制实现的。

ThreadLocal

线程本地变量。当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程。

ThreadLocal原理

每个线程都有一个ThreadLocalMapThreadLocal内部类),Map中元素的键为ThreadLocal,而值对应线程的变量副本。

调用threadLocal.set()-->调用getMap(Thread)-->返回当前线程的ThreadLocalMap<ThreadLocal, value>-->map.set(this, value),this是threadLocal本身。源码如下:

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

调用get()-->调用getMap(Thread)-->返回当前线程的ThreadLocalMap<ThreadLocal, value>-->map.getEntry(this),返回value。源码如下:

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

threadLocals的类型ThreadLocalMap的键为ThreadLocal对象,因为每个线程中可有多个threadLocal变量,如longLocalstringLocal

public class ThreadLocalDemo {
    ThreadLocal<Long> longLocal = new ThreadLocal<>();

    public void set() {
        longLocal.set(Thread.currentThread().getId());
    }
    public Long get() {
        return longLocal.get();
    }

    public static void main(String[] args) throws InterruptedException {
        ThreadLocalDemo threadLocalDemo = new ThreadLocalDemo();
        threadLocalDemo.set();
        System.out.println(threadLocalDemo.get());

        Thread thread = new Thread(() -> {
            threadLocalDemo.set();
            System.out.println(threadLocalDemo.get());
        }
        );

        thread.start();
        thread.join();

        System.out.println(threadLocalDemo.get());
    }
}

ThreadLocal并不是用来解决共享资源的多线程访问问题,因为每个线程中的资源只是副本,不会共享。因此ThreadLocal适合作为线程上下文变量,简化线程内传参。

ThreadLocal内存泄漏的原因?

每个线程都有⼀个ThreadLocalMap的内部属性,map的key是ThreaLocal,定义为弱引用,value是强引用类型。垃圾回收的时候会⾃动回收key,而value的回收取决于Thread对象的生命周期。一般会通过线程池的方式复用线程节省资源,这也就导致了线程对象的生命周期比较长,这样便一直存在一条强引用链的关系:Thread --> ThreadLocalMap-->Entry-->Value,随着任务的执行,value就有可能越来越多且无法释放,最终导致内存泄漏。

解决⽅法:每次使⽤完ThreadLocal就调⽤它的remove()⽅法,手动将对应的键值对删除,从⽽避免内存泄漏。

ThreadLocal使用场景有哪些?

ThreadLocal适用场景:每个线程需要有自己单独的实例,且需要在多个方法中共享实例,即同时满足实例在线程间的隔离与方法间的共享,这种情况适合使用ThreadLocal。比如Java web应用中,每个线程有自己单独的Session实例,就可以使用ThreadLocal来实现。

AQS原理

AQS,AbstractQueuedSynchronizer,抽象队列同步器,定义了一套多线程访问共享资源的同步器框架,许多并发工具的实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch

AQS使用一个volatile的int类型的成员变量state来表示同步状态,通过CAS修改同步状态的值。当线程调用 lock 方法时 ,如果 state=0,说明没有任何线程占有共享资源的锁,可以获得锁并将 state加1。如果 state不为0,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。

private volatile int state;//共享变量,使用volatile修饰保证线程可见性

同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态(独占或共享)构造成为一个节点(Node)并将其加入同步队列并进行自旋,当同步状态释放时,会把首节点中的后继节点对应的线程唤醒,使其再次尝试获取同步状态。

ReentrantLock 是如何实现可重入性的?

ReentrantLock内部自定义了同步器sync,在加锁的时候通过CAS算法,将线程对象放到一个双向链表中,每次获取锁的时候,会检查当前占有锁的线程和当前请求锁的线程是否一致,如果一致,同步状态加1,表示锁被当前线程获取了多次。

源码如下:

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

锁的分类

公平锁与非公平锁

按照线程访问顺序获取对象锁。synchronized是非公平锁,Lock默认是非公平锁,可以设置为公平锁,公平锁会影响性能。

public ReentrantLock() {
    sync = new NonfairSync();
}

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

共享式与独占式锁

共享式与独占式的最主要区别在于:同一时刻独占式只能有一个线程获取同步状态,而共享式在同一时刻可以有多个线程获取同步状态。例如读操作可以有多个线程同时进行,而写操作同一时刻只能有一个线程进行写操作,其他操作都会被阻塞。

悲观锁与乐观锁

悲观锁,每次访问资源都会加锁,执行完同步代码释放锁,synchronizedReentrantLock属于悲观锁。

乐观锁,不会锁定资源,所有的线程都能访问并修改同一个资源,如果没有冲突就修改成功并退出,否则就会继续循环尝试。乐观锁最常见的实现就是CAS

适用场景:

  • 悲观锁适合写操作多的场景。
  • 乐观锁适合读操作多的场景,不加锁可以提升读操作的性能。

乐观锁有什么问题?

乐观锁避免了悲观锁独占对象的问题,提高了并发性能,但它也有缺点:

  • 乐观锁只能保证一个共享变量的原子操作。
  • 长时间自旋可能导致开销大。假如CAS长时间不成功而一直自旋,会给CPU带来很大的开销。
  • ABA问题。CAS的原理是通过比对内存值与预期值是否一样而判断内存值是否被改过,但是会有以下问题:假如内存值原来是A, 后来被一条线程改为B,最后又被改成了A,则CAS认为此内存值并没有发生改变。可以引入版本号解决这个问题,每次变量更新都把版本号加一。

什么是CAS?

CAS全称Compare And Swap,比较与交换,是乐观锁的主要实现方式。CAS在不使用锁的情况下实现多线程之间的变量同步。ReentrantLock内部的AQS和原子类内部都使用了CAS。

CAS算法涉及到三个操作数:

  • 需要读写的内存值V。
  • 进行比较的值A。
  • 要写入的新值B。

只有当V的值等于A时,才会使用原子方式用新值B来更新V的值,否则会继续重试直到成功更新值。

AtomicInteger为例,AtomicIntegergetAndIncrement()方法底层就是CAS实现,关键代码是 compareAndSwapInt(obj, offset, expect, update),其含义就是,如果obj内的valueexpect相等,就证明没有其他线程改变过这个变量,那么就更新它为update,如果不相等,那就会继续重试直到成功更新值。