Java并发编程面试视角

1,323 阅读1小时+

这篇文章主要是收集并发编程面试时会出现的问题,会一直补充
给一张图先看看并发编程都需要掌握哪些知识

java并发_wps图片.png

并发基础

问题:为什么需要多线程?

答:使用多线程可提高性能,主要是能降低延迟,提高吞吐量
那么想要提高性能,对应的方法主要两个方向:一是优化算法,二是将硬件的性能发挥到极致。而在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来说,就是提升 I/O 的利用率和 CPU 的利用率。并发编程不是要解决单一硬件的利用率的,也就是它不是解决CPU利用率也不是解决I/O利用率,这些都是操作系统去解决的,多线程解决的是CPU和I/O设备综合利用率的问题。下面举个例子:
假设CPU计算和I/O操作交叉执行,而且CPU计算和I/O操作耗时都是1:1(举例子,现实不可能这个比例)那么CPU 的利用率和 I/O 设备的利用率都是 50%。

image.png

假如两个线程当线程 A 执行 CPU 计算的时候,线程 B 执行 I/O 操作;当线程 A 执行 I/O 操作的时候,线程 B 执行 CPU 计算,这样 CPU 的利用率和 I/O 设备的利用率就都达到了 100%。

image.png

在单核时代,多线程主要是用来平衡CPU和I/O设备的。

问题:创建线程有几种方式?你觉得哪种方式更好

答: 创建线程其实本质上就是1种,新建线程我们必须通过Thread,但是我们通常分为两种形式,一种是Runnable接口,一种是继承Thread类,另外还有其他方式也可以创建线程,比如说线程池、定时器、Lambda、内部类等等,但是本质都逃不过这两种。
实现Runnable接口要比继承Thread方式好,原因在于:

  • 从代码架构角度考虑,具体执行的任务(即run方法里面的代码)应该和创建线程(Thread)分开的,也就是任务要和线程的创建解耦,不应该把线程的创建和任务执行混在一起。
  • 如果使用继承Thread,那么每次去执行任务,都需要新建一个线程,而新建线程的代价是比较大的(需要创建、执行、销毁),如果使用Runnable,我们就可以利用线程池工具,这样就大大减少了线程的创建,同样也说明,任务和线程分开的好处,好在资源的节约上。
  • Java是单继承,但是可以多实现

问题:一个线程调用两次start() 方法会怎么样?为什么?

答:会抛出异常,原因是在调用start()方法开始的时候会对线程状态检查,如果发现状态改变就会报错,而 start()会让线程状态由new状态改为runnable状态。

问题:既然start()方法会调用run()方法,为什么我们选择调用start()方法,而不是直接调用run()方法呢?

答案:因为调用start()才是真正去开启线程,会去执行线程的各个生命周期,而调用run()方法只是执行一个普通方法。

问题:Java中运行一半的线程能否强制杀死?如何优雅关闭?

答:第一个问题答案肯定是不能,Java提供了 stop()、destory()等函数,但是这些函数都是过期的不建议使用的,原因是强制杀死线程,则线程中所使用的资源,比如文件描述符,网络连接等都不能正常关闭。所以线程运行之后合理的办法就是让其运行完成,释放资源,然后退出。如果是一个循环就需要线程通信机制,通知其退出

问题:如何正确停止线程?volatile 标记位的停止方法是否可以?

答:Java中没有强制停止线程的方式,早期Java提供的stop()、destory()等方法都被标注为过期方法,不建议使用的。停止线程只能通过通知、协作的方式。
一般有两种方式:
1.使用Interrupt来通知(推荐)
2.使用volatile标志一个字段,通过判断这个字段true/false退出线程(有局限性)

  • 使用Interrupt来通知
    while (!Thread.currentThread().isInterrupted() && more work to do) {     do more work } 首先通过 Thread.currentThread().isInterrupt() 判断线程是否被中断,随后检查是否还有工作要做。

使用这种方式的时候需要注意,执行任务间有 sleep()wait() 等可以让线程进入阻塞的方法使线程休眠了,而处于休眠中的线程被中断,那么线程是可以感受到中断信号的,并且会抛出一个 InterruptedException 异常,同时清除中断信号,将中断标记位设置成 false。这个时候处理try{}catch(InterruptedException e){} 不要把异常吞并(捕获异常之后啥也没干),这时候可以不捕获遗产直接抛出或者再次中断。
抛出异常案例:

/**
 * 描述:最佳实践:catch了InterruptedExcetion之后的优先选择:
 在方法签名中抛出异常 那么在run()就会强制try/catch
 */
public class RightWayStopThreadInProd implements Runnable {

    @Override
    public void run() {
        while (true && !Thread.currentThread().isInterrupted()) {
            System.out.println("go");
            try {
                throwInMethod();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                //保存日志、停止程序
                System.out.println("保存日志");
                e.printStackTrace();
            }
        }
    }

    private void throwInMethod() throws InterruptedException {
            Thread.sleep(2000);
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new RightWayStopThreadInProd());
        thread.start();
        Thread.sleep(1000);
        thread.interrupt();
    }
}

再次中断案例:

/**
 * 描述:最佳实践2:在catch子语句中调用Thread.currentThread().interrupt()
 来恢复设置中断状态,以便于在后续的执行中,依然能够检查到刚才发生了中断
 * 回到刚才RightWayStopThreadInProd补上中断,让它跳出
 */
public class RightWayStopThreadInProd2 implements Runnable {

    @Override
    public void run() {
        while (true) {
            if (Thread.currentThread().isInterrupted()) {
                System.out.println("Interrupted,程序运行结束");
                break;
            }
            reInterrupt();
        }
    }

    private void reInterrupt() {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt(); //恢复中断
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new RightWayStopThreadInProd2());
        thread.start();
        Thread.sleep(1000);
        thread.interrupt();
    }
}
  • 使用volatile方式
/**
 * 描述:     演示用volatile的局限:part1 看似可行
 */
public class WrongWayVolatile implements Runnable {

    private volatile boolean canceled = false;

    @Override
    public void run() {
        int num = 0;
        try {
            while (num <= 100000 && !canceled) {
                if (num % 100 == 0) {
                    System.out.println(num + "是100的倍数。");
                }
                num++;
                Thread.sleep(1);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        WrongWayVolatile r = new WrongWayVolatile();
        Thread thread = new Thread(r);
        thread.start();
        Thread.sleep(5000);
        r.canceled = true;
    }
}

这样的方式是可行的,但是它有局限性

  • 使用volatile方式的局限性 volatile的boolean无法处理长时间阻塞的情况,下面以生产者消费者模式举例:
/**
 * 描述:     演示用volatile的局限part2 陷入阻塞时,volatile是无法线程的 
 此例中,生产者的生产速度很快,消费者消费速度慢,所以阻塞队列满了以后,
 生产者会阻塞,等待消费者进一步消费
 */
public class WrongWayVolatileCantStop {

    public static void main(String[] args) throws InterruptedException {
        ArrayBlockingQueue storage = new ArrayBlockingQueue(10);

        Producer producer = new Producer(storage);
        Thread producerThread = new Thread(producer);
        producerThread.start();
        Thread.sleep(1000);

        Consumer consumer = new Consumer(storage);
        while (consumer.needMoreNums()) { ----(1)
            System.out.println(consumer.storage.take()+"被消费了");
            Thread.sleep(100);
        }
        System.out.println("消费者不需要更多数据了。");

        //一旦消费不需要更多数据了,我们应该让生产者也停下来,但是实际情况
        producer.canceled=true;
        System.out.println(producer.canceled);
    }
}

class Producer implements Runnable {

    public volatile boolean canceled = false;

    BlockingQueue storage;

    public Producer(BlockingQueue storage) {
        this.storage = storage;
    }


    @Override
    public void run() {
        int num = 0;
        try {
            while (!canceled) {
                if (num % 100 == 0) {
                    storage.put(num); -----------(2)
                    System.out.println(num + "是100的倍数,被放到仓库中了。");
                }
                num++;
            }
        } catch (InterruptedException e) {
            e.printStackTrace();//这里演示的不是通过判断中断形式的 可以直接屏蔽中断
        } finally {
            System.out.println("生产者结束运行");  -----(3)
        }
    }
}

class Consumer {

    BlockingQueue storage;

    public Consumer(BlockingQueue storage) {
        this.storage = storage;
    }

    public boolean needMoreNums() {
        if (Math.random() > 0.95) {
            return false;
        }
        return true;
    }
}

上面代码运行后发现 在代码(1)处,当消费者不再需要生产数据时,canceled被设置为true了, 那么代码运行应该被停止,会被执行到(3)处的代码,打印出 “生产者结束运行" ,事实上,代码并没有结束运行。
所以在陷入阻塞时,volatile无法停止线程。那么为什么会这样呢?
其实是代码阻塞在(2)处了,那么就不会继续循环了,也不会判断 canceled 是否为true了。这种情况在Java设计时就考虑到了,所以才会用 interrupt方法来中断线程的正宗方法,因为interrrupt方法即便是发生阻塞时也能响应中断。

问题:下面main(...)函数开启一个线程,请问:当main函数执行结束,该线程是否强制退出?进程是否强制退出?

public static void main(String[] args) {
        System.out.println("main thread start");
        Thread t1 = new Thread(() -> {
            while (true){
                System.out.println(Thread.currentThread().getName());
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t1.start();
        System.out.println("main thread end");
    }

答:不会,但是在t1.start()前面加上t1.setDaemon(true);后,当main(...)函数退出后,线程t1就会退出,整个进程也会退出 在JVM运行的多个线程被分为两类:守护线程和非守护线程,默认开启的线程都是非守护线程。Java规定,当所有非守护线程退出,JVM进程就会退出,而守护线程不影响整个JVM进程退出。

问题:下面这些情况会抛出Interrupted异常吗?

 public void run(){
            while (!stopped){

                int a = 1, b = 2;
                int c = a + b;
                System.out.println("thread is executing");
            }
        }

如上面这段代码,如果在主线程中调用t.interrupt(),线程会不会抛异常? 如果代码修改为这个线程阻塞在一个synchronized关键字的地方,正准备拿锁,如下代码所示

public void run(){
            while (!stopped){
                synchronized (this){
                    int a = 1, b = 2;
                    int c = a + b;
                    System.out.println("thread is executing");
                }
            }
        }

这个时候,在主线程中调用一句t.interrupt(),请问该线程是否会抛出异常?

答:上面两段代码都不会抛出异常,只有那些声明了会抛出InterruptedException 的函数才会抛出异常,也就是下面这些常用的函数:

public static void sleep(long millis) throws InterruptedException{}
public final void wait() throws InterruptedException{}
public final void join() throws InterruptedException{}

问题:线程之间有那些状态?如何转变?

image.png 答:线程状态迁移如上图。
能够被中断的阻塞称为轻量级阻塞,对应的线程状态是WAITING或者TIMED_WAITING;而像synchronized 这种不能被中断的阻塞称为重量级阻塞,对应的状态是BLOCKEDNEW状态调用start()进入RUNNING或者READY状态。如果没有调用阻塞函数,线程只会在RUNNINGREADY之间切换,也就是系统的时间片调度。可以通过调用yield()函数,放弃对CPU的占用,其他方式无法介入这两种状态改变。
调用阻塞函数会进入WAITING或者TIMED_WAITING状态,两者的区别只是前者为无限期阻塞,后者则传入了一个时间参数,阻塞一个有限的时间。
使用synchronized会进入BLOCKED状态。
BlockedWaiting 的区别是 Blocked 在等待其他线程释放 monitor 锁,而 Waiting 则是在等待某个条件,比如 join 的线程执行完毕,或者是 notify()/notifyAll()

状态说明

  • NEW状态(初始化状态) 实现Runnable接口和继承Thread可以得到一个线程类,new一个实例出来,线程就进入了初始化状态。

  • RUNNABLE(就绪,运行中状态)
    READY 就绪

    1. 就绪状态只是说你自个儿运行,调度程序没有挑选到你,你就永远是就绪状态。
    2. 调用线程的start()方法,此线程进入就绪状态。
    3. 当前线程sleep()方法结束,其他线程join()结束,等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入就绪状态。
    4. 当前线程时间片用完了,调用当前线程的yield()方法,当前线程进入就绪状态。
    5. 锁池里的线程拿到对象锁后,进入就绪状态。
  • RUNNING 运行中状态
    线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一一种方式。

  • BLOCKED(阻塞状态) 阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)之前时的状态。

  • WAITING(等待状态) 调用sleep或是wait方法后线程处于WAITING状态,等待被唤醒。

  • TIMED_WAITING(等待超时状态) 调用sleep或是wait方法后线程处于TIMED_WAITING状态,等待被唤醒或时间超时自动唤醒。

  • TERMINATED(终止状态)

    • 当线程的run()方法完成时,或者主线程的main()方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是,它 已经不是一个单独执行的线程。线程一旦终止了,就不能复生。
    • 在一个终止的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。

状态切换

  • NEW到RUNNABLE 状态 image.png

  • RUNNABLE与BLOCKED 的状态转换 image.png 目前只有当线程等待synchronized 的隐式锁时,线程才会从RUNNABLE 向BLOCKED 转换

  • RUNNABLE与WAITING 的状态转换
    image.png 有3种场景会触发线程从RUNNABLE向WAITING 转换;

    1. 获得 synchronized 隐式锁的线程,调用 Object.wait() 方法。
    2. 另外一种,调用线程同步 Thread.join() 方法。 例如有一个线程对象 thread A,当调用 A.join() 的时候,执行这条语句的线程会等待 thread A 执行完,而等待中的这个线程,其状态会从 RUNNABLE 转换到 WAITING。当线程 thread A 执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。
    3. 最后一种,调用 LockSupport.park() 方法。Java 并发包中的锁,都是基于LockSupport 对象实现的。调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。调用 LockSupport.unpark(Thread thread) 方法,可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。
  • RUNNABLE与TIMED_WAITING 的状态转换
    image.png
    有5种场景会触发RUNNABLE向TIMED_WAITING转换:

    1. 调用带超时参数的 Thread.sleep(long millis) 方法;
    2. 获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;
    3. 调用带超时参数的 Thread.join(long millis) 方法;
    4. 调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;
    5. 调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。
  • RUNNABLE到TERMINATED 状态 image.png

问题:t.isInterrupted()与Thread.interrupted()的区别?

答: t.interrupted()相当于给线程发送了一个唤醒的信号,如果线程此时恰好处于WAITING或者TIMED_WAITING状态,就会抛出一个InterruptedException,并且线程被唤醒。如果线程没有被阻塞,则线程什么都不做。 这两个函数都是线程用来判断自己是否收到过中断信号的,前者是非静态函数,后者是静态函数。二者的区别在于,前者只是读取中断状态,不修改状态;后者不仅读取中断状态,还会重置中断标志位。

问题:对synchronized的理解如何?

答:
1.synchronized锁得对象
synchronized其实是给对象加了一把锁,对于修饰非静态成员函数,锁的是当前对象this,对于静态成员函数,锁的是当前类的class对象

2.锁的本质
锁其实就是一个对象,这个对象需要完成下面事情:
1)对象内部得有一个标志位(state变量),记录自己有没有被某个线程占用。最简单的情况是这个state有0、1两个取值,0表示没有线程占用这个锁,1表示有某个线程占用了这个锁。
2)对象得记录占用锁的线程ID(知道自己被哪个线程占用)
3)对象需要维护线程ID列表,记录其他阻塞的线程。在当前线程释放锁时,会从阻塞线程列表里面取一个线程唤醒
锁是个对象,共享资源也是一个对象,那么可以把锁和共享资源使用一个对象: synchronized(this){…} 也可以使用两个对象,锁加在obj上,共享资源是另一个对象: synchronized(obj){…}

问题:如何设计一个生产者-消费者模型

image.png 答:
如上图所示:一个内存队列,多个生产者线程往内存队列中放数据;多个消费者线程从内存队列中取数据。

    • 内存队列本身要加锁,才能实现线程安全。
    • 阻塞。当内存队列满了,生产者放不进去时,会被阻塞;当内存队列是空的时候,消费者无事可做,会被阻塞。
    • 双向通知。消费者被阻塞之后,生产者放入新数据,要notify()消费者;反之,生产者被阻塞之后,消费者消费了数据,要notify()生产者。
如何阻塞?
  • 线程自己阻塞自己,也就是生产者、消费者线程各自调用wait()和notify()
  • 用一个阻塞队列,当取不到或者放不进去数据的时候,入队/出队函数本身就是阻塞的。这也就是BlockingQueue的实现
如何双向通知?
  • wait()与notify()机制
  • Condition机制

伪代码:有问题。。。。。。

    public void enqueue() {
        synchronized (queue) {
            while (queue.full()) {
                queue.wait();
            }
            // 入队操作
            queue.notify();
        }
    }
    public void dequeue() {
        synchronized (queue) {
            while (queue.empty()) {
                queue.wait();
            }
            // 出队操作
            queue.notify();
        }
    }

问题:为什么 wait 方法必须在 synchronized 保护的同步代码中使用?

答案: 在Javadoc中描述 wait()时是这样的:

     * As in the one argument version, interrupts and spurious wakeups are
     * possible, and this method should always be used in a loop:
     * <pre>
     *     synchronized (obj) {
     *         while (&lt;condition does not hold&gt;)
     *             obj.wait();
     *         ... // Perform action appropriate to condition
     *     }
     * </pre>
     * This method should only be called by a thread that is the owner
     * of this object's monitor. See the {@code notify} method for a
     * description of the ways in which a thread can become the owner of
     * a monitor.

上面的英文说,这个方法可能会被发生interrupts(中断)或者interrupts(虚假唤醒),所以需要放在循环中执行。因为循环会判断是否满足条件,如果这个时候线程被 虚假唤醒 ,那么while中循环判断条件,不满足条件还是会阻塞等待。
那么为什么要设计成 synchronized的呢?

class BlockingQueue {
    Queue<String> buffer = new LinkedList<String>();
    public void give(String data) {
        buffer.add(data);
        notify();  // Since someone may be waiting in take
    }

    public String take() throws InterruptedException {
        while (buffer.isEmpty()) {
            wait();
        }
        return buffer.remove();
    }
}

这段代码是典型的 生产者-消费者 模式。

1.首先,消费者线程调用 take 方法并判断 buffer.isEmpty 方法是否返回 true,若为 true 代表buffer是空的,则线程希望进入等待,但是在线程调用 wait 方法之前,就被调度器暂停了,所以此时还没来得及执行 wait 方法。
2.此时生产者开始运行,执行了整个 give 方法,它往 buffer 中添加了数据,并执行了 notify 方法,但 notify 并没有任何效果,因为消费者线程的 wait 方法没来得及执行,所以没有线程在等待被唤醒。
3.此时,刚才被调度器暂停的消费者线程回来继续执行 wait 方法并进入了等待。

因为消费者需要判断然后等待,这是两个操作,不是一个原子操作,它在中间被打断了,是线程不安全的。
而且还有一点,调用wait()需要释放锁,释放锁的前提是获取到锁,所以要配合synchronized使用。

问题:为什么 wait/notify/notifyAll 被定义在 Object 类中,而 sleep 定义在 Thread 类中?

答:这里主要有两个原因:

  • 因为 Java 中每个对象都有一把称之为 monitor 监视器的锁,由于每个对象都可以上锁,这就要求在对象头中有一个用来保存锁信息的位置。这个锁是对象级别的,而非线程级别的,wait/notify/notifyAll 也都是锁级别的操作,它们的锁属于对象,所以把它们定义在 Object 类中是最合适,因为 Object 类是所有对象的父类。
  • 因为如果把 wait/notify/notifyAll 方法定义在 Thread 类中,会带来很大的局限性,比如一个线程可能持有多把锁,以便实现相互配合的复杂逻辑,假设此时 wait 方法定义在 Thread 类中,如何实现让一个线程持有多把锁呢?又如何明确线程等待的是哪把锁呢?既然我们是让当前线程去等待某个对象的锁,自然应该通过操作对象来实现,而不是操作线程。

问题: wait/notify 和 sleep 方法的异同?

答:
相同点

  • 他们都可以让线程阻塞
  • 它们都可以响应interrupt中断:在等待的过程中如果收到中断信号,都可以进行响应,并抛出InterruptedException

不同点

  • wait方法必须在synchronized保护的代码中使用,而sleep方法并没有这个要求
  • 在同步代码中执行sleep方法时,并不会释放monitor锁,但执行wait方法时会主动释放monitor锁
  • sleep方法中要求必须定义一个时间,时间到期后会主动恢复或者中断提前唤醒线程,而对于没有参数wait方法而言,意味着永久等待,直到被中断或者唤醒才能恢复,他并不会主动恢复
  • wait/notify是Object方法,而sleep是Thread类的方法

问题:为什么wait()的时候必须释放锁

答:
当线程A进入synchronized(obj1)中之后,也就是对obj1上了锁。此时,调用wait()进入阻塞状态,一直不能退出synchronized代码块;那么,线程B永远无法进入synchronized(obj1)同步块里,永远没有机会调用notify(),岂不是死锁了。
所以wait()内部:

wait() {
 // 释放锁obj1
 // 阻塞,等待被其他线程notify
 // 重新拿到锁
}

问题:如何在两个线程间共享数据?

答:可以通过共享对象来实现这个目的,或者是使用像阻塞队列这样并发的数据结构。用 wait 和 notify 方法实现了生产者消费者模型。

问题:在多线程中,什么是上下文切换(context-switching)?

答:上下文切换是存储和恢复 CPU 状态的过程,它使得线程执行能够从中断点恢复执行。上下文切换是多任务操作系统和多线程环境的基本特征。

问题:CallableRunnable 的不同?

答:不同之处:

  • 方法名,Callable 规定的执行方法是 call(),而 Runnable 规定的执行方法是 run();
  • 返回值,Callable 的任务执行后有返回值,而 Runnable 的任务执行后是没有返回值的;
  • 抛出异常,call() 方法可抛出异常,而 run() 方法是不能抛出受检查异常的;
  • 和 Callable 配合的有一个 Future 类,通过 Future 可以了解任务执行情况,或者取消任务的执行,还可获取任务执行的结果,这些功能都是 Runnable 做不到的,Callable 的功能要比 Runnable 强大。

问题:如何解决并发问题?

答:解决并发问题主要可以分为两大类,锁机制和无锁机制 image.png

  • 无锁
    • 局部变量
      局部变量仅仅存在于每个线程的工作内存中,不存在共享的情况,自然就没有并发安全问题

    • 不可变对象
      不可变对象一旦创建就不会改变,无论多个线程对他操作,他都是不可变的,自然也没有并发问题

    • ThreadLocal
      ThreadLocal的本质是每个线程都有自己的副本,每个线程的副本是互不影响的,自然就没有并发问题 image.png

    • cas 原子类 CAS的意思是Compare And Swap,意思是“比较并置换”,CAS机制当中使用了3个基本操作数:内存地址 V,旧的预期值A,要修改的新值B,只有当内存地址V所对应的值和旧的预期值A相等的时候,才会将内存地址V对应的值更新为新的值B。
      在Java中的实现则通常是指以英文Atomic为前缀的一系列类,它们都采用了CAS的思想。Atomic使用的是Unsafe类提供硬件级别的原子操作。来看看Unsafe的方法 public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v = this.getIntVolatile(o, offset); } while(!this.weakCompareAndSetInt(o, offset, v, v + delta)); return v; } 通过v获取一个旧的值,接着CAS操作来对数据进行比较并置换,操作失败就进入while循环,直到成功为止。

  • 有锁
    • Syncronized
    • ReentrantLock
      Synchronized和ReentrantLock都是采用了悲观锁的策略。他们的实现非常类似,只不过一种是通过语言层面来实现 (Synchronized),另一种是通过编程方式实现(ReentrantLock)。
      加锁原理:
      image.png

线程池

问题:什么是线程池?

答:为了避免系统频繁的创建和销毁线程,我们可以将创建的线程进行复用,将线程缓存起来,创建线程变成了从线程池获取空闲的线程,关闭线程变成了向池子中归还线程。

image.png

问题:为什么需要线程池?

答:这个问题可以转换为线程池有什么优点或者不用线程池有什么缺点。

不用线程池的缺点

  • 反复创建线程系统开销比较大,每个线程创建和销毁都需要时间,如果任务比较简单,那么就有可能导致创建和销毁线程消耗的资源比线程执行任务本身消耗的资源还要大。
  • 过多的线程会占用过多的内存等资源,还会带来过多的上下文切换,同时还会导致系统不稳定。 线程池优点
  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 线程池可以统一管理资源。比如线程池可以统一管理任务队列和线程,可以统一开始或结束任务,比单个线程逐一处理任务要更方便、更易于管理,同时也有利于数据统计,比如我们可以很方便地统计出已经执行过的任务的数量。

问题:线程池有哪些参数?线程池的工作流程是怎么样的?

答:线程池有以下参数:

  • corePoolSize(核心线程数):创建之后不会被销毁
  • maximumPoolSize(最大线程数):创建后在keepAliveTime时间后没有任务执行会被销毁
  • keepAliveTime+时间单位:空闲线程存活时间
  • ThreadFactory(线程工厂)
  • workQueue(阻塞队列)
  • handler(拒绝策略)

执行流程可以用以下图解释:

image.png

  • 当前线程数小于corePoolSize,则创建新线程来执行任务;
  • 当前线程数大于等于corePoolSize,则将任务加入BlockingQueue;
  • 若队列已满,则创建新线程处理任务;
  • 若线程数超过maxinumPoolsize,则任务将被拒绝,并调用RejectExecutionHandler.rejectedExecution() 方法;

线程池原理: threadpool.jpg

问题:线程池提供了有哪几种拒绝策略?拒绝时机是什么?

答:
线程池拒绝策略的时机主要是两种情况:

  • 当我们调用shutdown等方法关闭线程池后,即便此时还有任务没有完成,因为线程池关闭,这时候如果提交任务,就会被拒绝。
  • 线程池没有能力再处理新的任务,也就是工作已经非常饱和时。

拒绝策略:

  • AbortPolicy:这种拒绝策略在拒绝任务时,会直接抛出一个类型为 RejectedExecutionExceptionRuntimeException,让你感知到任务被拒绝了,于是你便可以根据业务逻辑选择重试或者放弃提交等策略。
  • DiscardPolicy:新任务被提交后直接被丢弃掉,不会给你任何的通知,存在一定的风险,可能造成数据丢失。
  • DiscardOldestPolicy:新任务被提交后会丢弃存活时间最长的任务,同理它也存在一定的数据丢失风险。
  • CallerRunsPolicy:新任务被提交后会这个任务交于提交任务的线程执行,也就是谁提交任务,谁就负责执行任务。这样做主要有两点好处。
    • 第一是新提交的任务不会被丢弃,这样也就不会造成业务损失。
    • 第二是由于谁提交任务谁就要负责执行任务,这样提交任务的线程就得负责执行任务,而执行任务又是比较耗时的,在这段期间,提交任务的线程被占用,也就不会再提交新的任务,减缓了任务提交的速度,相当于是一个负反馈。在此期间,线程池中的线程也可以充分利用这段时间来执行掉一部分任务,腾出一定的空间,相当于是给了线程池一定的缓冲期。

问题:为什么阿里规约中不建议自动创建线程池?也就是使用Executors创建的线程池。

答:因为使用 Executors 创建线程池不太安全。在回答这个问题之前我们先要知道通过 Executors 可以创建哪些线程池。

  • FixedThreadPool: 固定线程数的线程池,它的核心线程数和最大线程数是一样的,newFixedThreadPool 内部实际还是调用了 ThreadPoolExecutor 构造函数。 它的问题是队列使用的是没有容量上线的LinkedBlockingQueue,这样如果线程处理任务很慢,而当任务特别多的时候,队列会堆积大量的任务,可能会造成OutOfMemoryError
public static ExecutorService newFixedThreadPool(int nThreads) { 
    return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());

}
  • SingleThreadExecutor:单个线程的线程池,和 newFixedThreadPool 是一样的,只不过把核心线程数和最大线程数都直接设置成了 1,但是任务队列仍是无界的 LinkedBlockingQueue,所以同样有newFixedThreadPool内存溢出的风险。
public static ExecutorService newSingleThreadExecutor() { 
    return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(11,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()));
}
  • CachedThreadPool:可缓存线程池,它使用的队列是SynchronousQueueSynchronousQueue 本身并不存储任务,而是对任务直接进行转发,这是没问题的。但是构造函数第二个参数是 Integer.MAX_VALUE,这说明它不限制最大线程数,也就是当任务特别多的时候会创建特别多的线程,导致内存溢出。
public static ExecutorService newCachedThreadPool() { 
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());
}
  • ScheduledThreadPool:定时或周期性执行任务的线程池,它使用的任务队列是 DelayedWorkQueue,是个延迟并且无界的队列,因此它也有上面说的内存溢出的风险。
public ScheduledThreadPoolExecutor(int corePoolSize) { 
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,new DelayedWorkQueue());
}
  • SingleThreadScheduledExecutor:和 ScheduledThreadPool 有一样的风险。 所以,使用Executors创建的线程池都有风险,相比较而言,我们自己手动创建会更好,因为我们可以更加明确线程池的运行规则,可以选择适合自己的线程数量,可以在必要的时候拒绝新任务的提交,避免资源耗尽的风险。

问题:线程池如何设置线程数?

答:设置线程数我们需要根据这样的一个结论为导向:

  • 线程的平均工作时间所占用的比例越高,就需要越少的线程。
  • 线程的平均等待时间所占用的比例越高,就需要越多的线程。
  • 针对不同的程序需要实际的压测从而得到合适的选择。

任务是CPU密集型的(比如加密、解密、压缩、计算等一系列需要大量耗费 CPU 资源的任务),线程数设置为1~2倍 CPU数,因为这种任务会占用大量CPU时间,CPU几乎满负荷的工作,如何这个时候设置很多线程会造成不必要的上下文切换,性能反而不好。

任务是IO密集型的(比如数据库、文件的读写,网络通信等任务),这些任务不会特别消耗CPU资源,但是IO很耗时,所以CPU会有很多等待,这个时候线程数量可以设置大很多倍,公式 :线程数=CPU核心数 * (1+线程等待时间/线程工作时间),也需要考虑系统其它负载,可根据实际压测情况确定线程数。

问题:如何正确关闭线程池?shutdown 和 shutdownNow 的区别?

答:在 ThreadPoolExecutor有几种个线程池关闭相关的方法:

void shutdown;
boolean isShutdown;
boolean isTerminated;
boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException;
List<Runnable> shutdownNow;

shutdown()方法是安全的关闭线程池,调用shutdown方法后,不是立即关闭线程池,如果线程池中会有很多任务正在被执行或者等待队列中还有任务等待执行,那么它会等所有任务执行完毕再关闭线程池,但是调用这个方法后不能再提交新的任务了,会根据拒绝策略拒绝后续提交的任务。

isShutdown()方法可以判断线程池是否已经开始关闭,它不能判断是否完全关闭。

isTerminated()方法可以判断线程池是否已经完全关闭,所以如果调用shutdown后,还有任务在执行,这个时候调用 isShutdown返回 true,而调用 isTerminated返回的是 false

awaitTermination方法是判断线程池是否已经完全关闭了,和 isTerminated 类似,只不过,它接受一个等待时间。调用这个方法可能会发生以下情况:

  • 等待期间(包括进入等待状态之前)线程池已关闭并且所有已提交的任务(包括正在执行的和队列中等待的)都执行完毕,相当于线程池已经“终结”了,方法便会返回 true;
  • 等待超时时间到后,第一种线程池“终结”的情况始终未发生,方法返回 false;
  • 等待期间线程被中断,方法会抛出 InterruptedException 异常。

shutdownNow:立即关闭线程池,它首先会给线程池中的线程发送中断信号,尝试中断线程,然后会将等待队列中的任务返回给调用者,让调用者对这些任务做一些补救。
所以我们就可以根据自己的业务需要,选择合适的方法来停止线程池,比如通常我们可以用 shutdown() 方法来关闭,这样可以让已提交的任务都执行完毕,但是如果情况紧急,那我们就可以用 shutdownNow 方法来加快线程池“终结”的速度。

问题:Executor框架的两级调度模型是什么?

答:简单的来说就是Java线程被一对一映射为本地操作系统线程。Java线程启动的时候会启动一个本地操作系统线程,当该Java线程终止时,这个操作系统线程也会被回收。操作系统会调度所有线程并将它们分配给可用的CPU。

由此出现了应用程序通过Executor框架控制上层的调用,而下层的调度由操作系统的内核来控制,从而形成了两级的调度模型,并且下层的调度不受应用程序的控制,任务的两级调度模型如下图:

image.png

Java中各种锁

问题:Java中的锁可以分为哪几类?

答:根据分类标准我们把锁分为以下 7 大类别。

  • 偏向锁/轻量级锁/重量级锁;
  • 可重入锁/非可重入锁;
  • 共享锁/独占锁;
  • 公平锁/非公平锁;
  • 悲观锁/乐观锁;
  • 自旋锁/非自旋锁;
  • 可中断锁/不可中断锁。

问题:悲观锁和乐观锁的本质是什么?

答:
悲观锁,顾名思义比较悲观,就觉得不锁柱资源就会被别人线程争抢,所以悲观锁每次获取并修改数据时都会把数据锁住。
悲观锁典型案例:synchronized 关键字和 Lock 接口

乐观锁,比较乐观,认为自己操作资源时不会有别的线程来干扰,所以不会锁住对象,只是在更新资源的时候会去对比在我修改数据之间有没有别的线程修改过数据。如果没有修改则本次修改正常,如果被其他线程修改过了,则放弃本次修改,并选择报错或重试。它是一种基于冲突检测的并发策略,这种并发策略的实现不需要线程挂起,所以是非阻塞同步。乐观锁一般使用CAS算法实现。
乐观锁典型案例:Java并发包中Atomic原子类,数据库中 version 版本机制。

悲观锁和乐观锁比较:悲观锁会让得不到锁的线程阻塞,这种开销是固定的,悲观锁的原始开销要高于乐观锁,而乐观锁虽然一开始开销比悲观锁小,但如果一直拿不到锁或者并发量大竞争激烈,会导致不停的重试,那么消耗的资源会越来越多,甚至超过悲观锁。

使用场景:悲观锁适用于并发写入多、临界区代码复杂、竞争激烈场景,这种场景可以避免大量无用的反复尝试消耗。
乐观锁适用于读取,少部分修改场景,也适合读写都很多,但是竞争并不激烈的场景。

问题:谈谈CAS算法是什么样子的?

答:CAS(Compare and swap)是比较并交换的意思。它涉及三个操作数:内存值、预期值、新值。当且仅当内存值和预期值相等时才将内存值修改为新值。CAS具有原子性,它的原子性有CPU硬件指令实现保证。

问题:synchronized锁原理是什么?

答:略,专门文章补充

问题:跟 Synchronized 相比,可重入锁 ReentrantLock 其实现原理有什么不同?

答:其实锁的实现原理基本都是为了达到一个目的:
让所有线程都能看到某一个标记。
Synchronized 通过在对象头中设置标记实现这一目的,是一种JVM原生的锁实现方式,而ReentrantLock以及所有基于Lock接口的实现类,都是通过用一个 volitile修饰的int型变量,并保证每个线程都能拥有对该int的可见性和原子修改,其本质是基于AQS框架。

问题:为啥有 Synchronized 了,Java还有 ReentrantLock 锁?他们有什么相同和不同点?

答:其实 ReentrantLock 的出现不是为了替代 Synchronized,而是对 Synchronized做补充的。
相同点:

  • 都是用来保护资源线程安全的。
  • 都是可重入锁

不同点:

  • 从存在层次上讲,synchronized 是JVM层面的,ReentrantLock是Java API 级别的。
  • 从使用方式上讲, synchronized不需要显式加锁和释放锁,ReentrantLock需要自己加锁,需要在finally中释放锁。
  • 从同步机制上讲,synchronized通过Java对象头锁标记和Monitor对象实现同步。ReentrantLock 通过CAS、AQS(AbstractQueuedSynchronizer)LockSupport(用于阻塞和解除阻塞)实现同步。
  • 从功能上讲,ReentrantLock增加了高级功能,比如:可尝试获取锁、等待可中断、可实现公平锁等等

如何选择呢?

  • 如果能不用最好既不使用 ReentrantLock 也不使用 synchronized。因为在许多情况下你可以使用 java.util.concurrent 包中的机制,它会为你处理所有的加锁和解锁操作,也就是推荐优先使用工具类来加解锁。
  • 如果 synchronized 关键字适合你的程序, 那么请尽量使用它,这样可以减少编写代码的数量,减少出错的概率。因为一旦忘记在 finally 里 unlock,代码可能会出很大的问题,而使用 synchronized 更安全。
  • 如果特别需要 ReentrantLock 的特殊功能,比如尝试获取锁、可中断、超时功能等,才使用 ReentrantLock。

问题: Lock有哪些常用的方法?

答:方法纵览

public interface Lock {
    // 加锁,没有获取锁则阻塞等待
    void lock();
    
    // 加锁,没有获取锁的话,除非当前线程在获取锁期间被中断,否则便会一直尝试获取直到获取到为止
    void lockInterruptibly() throws InterruptedException;
    
    // 尝试加锁,拿到锁返回true,没有拿到锁返回false,不会阻塞等待,因此没有死锁问题
    boolean tryLock();
    
    // 尝试加锁,如果没有拿到锁,会在等待了一段指定的超时时间后,线程会主动放弃这把锁的获取,避免永久等待
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    
    // 解锁
    void unlock();
    Condition newCondition();
}

问题:什么是公平锁?什么是非公平锁?为什么需要非公平锁?

答:
公平锁是按照线程请求顺序来分配锁。
非公平锁不会按照请求顺序,在一定情况下,可以允许插队(注意,不是完全随机,而是在合适的机会下)。合适的机会是指,假设当前线程在请求获取锁的时候,恰好有一个线程释放锁,那么当前申请锁的线程就不顾等待的线程立刻插队。但是如果当前线程申请锁的时候,前一个线程并没有释放锁,那么当前线程还是会进入等待队列中。
非公平锁的出现是为了提高吞吐量的,考虑这种情况,假设线程A持有锁,线程B请求这把锁,由于线程A持有锁,那么线程B只能阻塞等待。当A释放锁时,本应该把B唤醒获取锁,但是如果C插队申请锁,非公平锁模式下,C会获取这把锁,因为唤醒B的开销比较大,有可能在B唤醒之前,C已经拿到锁并且执行完任务释放锁了。这时候B再来获取锁,那岂不是双赢。
从代码实现上看,公平锁申请锁的逻辑是先看看等待队列中是否有线程等待,如果有等待就不去获取锁。非公平锁申请锁时不管三七二一直接去获取锁,没有申请到锁才去排队。

问题:为什么需要读写锁?又有什么规则?

答:读写锁的出现是为了提高性能的,因为我们知道多个读操作是没有线程安全问题的。那么就可以允许多个线程读,提高效率。
设计思路就是:设计两把锁,读锁和写锁,获取读锁之后,只能读取数据,不能修改,获取写锁时可以读取也可以修改数据。读锁可以被多个线程持有,写锁只能一个线程持有。

/**

 * 描述:     演示读写锁用法

 */

public class ReadWriteLockDemo {

    private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);

    private static final ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();

    private static final ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();

    private static void read() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到读锁,正在读取");
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "释放读锁");
            readLock.unlock();
        }
    }

    private static void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到写锁,正在写入");
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "释放写锁");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> read()).start();
        new Thread(() -> read()).start();
        new Thread(() -> write()).start();
        new Thread(() -> write()).start();
    }
}

结果

Thread-0得到读锁,正在读取
Thread-1得到读锁,正在读取
Thread-0释放读锁
Thread-1释放读锁
Thread-2得到写锁,正在写入
Thread-2释放写锁
Thread-3得到写锁,正在写入
Thread-3释放写锁

问题:根据上面有各问题,既然读数据没有线程安全问题,那为什么要加读锁呢?不加锁不行吗?

答:读这个操作本身是没有安全问题的,但是如果即存在对共享变量的读,有存在对共享变量的写(也就是一个方法对共享变量写,另一个方法对共享变量读),这时候操作的话,假设读不加锁,那么在读的同时可能存在这个共享变量被写的情况,读取的可能就不是期望值了。

问题:读锁可以插队吗?

答:ReentrantReadWriteLock 可以设置公平锁模式和非公平锁模式。

// 公平锁模式
ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(true);

//非公平锁模式 默认情况
ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);
  • 公平锁时获取读锁前会检查readerShouldBlock() 方法,获取写锁前会检查writerShouldBlock()方法来决定是排队还是插队。 final boolean writerShouldBlock() { return hasQueuedPredecessors(); }

    final boolean readerShouldBlock() {
        return hasQueuedPredecessors();
    }
    

很明显,公平锁模式下,有线程排队时获取读锁/写锁都不能插队

  • 非公平锁模式下, writerShouldBlock()readerShouldBlock()实现 final boolean writerShouldBlock() { return false; // writers can always barge } final boolean readerShouldBlock() { return apparentlyFirstQueuedIsExclusive(); }

非公平锁获取写锁时可以插队。获取读锁时使用策略决定
假设线程1和线程2持有读锁,线程3想获取写锁,只能排队等待,这个时候线程4过来想获取读锁,就看下面的策略了。

  1. 第一种策略:可以插队
    由于线程1和2持有读锁,线程4可以共用读锁,所以就插队加入一起读取。这种策略线程4提高了效率,但是有一个很大问题,就是如果后面有很多线程想获取读锁都可以插队,那么最早想获取写锁的线程3只能一直等待陷入"饥饿"状态。

  2. 第二种策略:不可以插队 这种策略认为线程3已经提前排队了,所以线程4必须去排队。这样接下来就是线程3获取锁,可以避免"饥饿"现象。

ReentrantReadWriteLock 选择第二种策略,不可以插队。

问题:聊聊读写锁的升降级是什么样的?

答:升降级策略,只能从写锁降级为读锁,不能从读锁升级为写锁。

  • 支持锁降级
    更新缓存代码案例: public class CachedData {

        Object data;
        volatile boolean cacheValid;
        final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    
        void processCachedData() {
            // 判断数据前加读锁
            rwl.readLock().lock();
            if (!cacheValid) {
                //缓存失效 在获取写锁之前,必须首先释放读锁。
                rwl.readLock().unlock();
                // 更新缓存数据先获取写锁
                rwl.writeLock().lock();
                try {
                    //这里需要再次判断数据的有效性,因为在我们释放读锁和获取写锁的空隙之内,可能有其他线程修改了数据。
                    if (!cacheValid) {
                        data = new Object();
                        cacheValid = true;
                    }
                    //在不释放写锁的情况下,直接获取读锁,这就是读写锁的降级。
                    rwl.readLock().lock();
                } finally {
                    //释放了写锁,但是依然持有读锁
                    rwl.writeLock().unlock();
                }
            }
            try {
               // 这个时候只有读锁
                System.out.println(data);
            } finally {
                //释放读锁
                rwl.readLock().unlock();
            }
        }
    }
    

    以上代码就是读写锁的降级
    分析为啥需要降级?在上面代码中一直获取写锁最后去读数据它不香吗?为啥要这么复杂?
    其实是为了性能,因为如果一直持有写锁,假如后面的读操作是很费时的,那么由于写锁是排他锁,所以其他线程想读数据只能排队,而当前持有写锁的线程做的事其实是读操作。这时候降级可以提高整体性能。

  • 不支持锁的升级
    代码案例: final static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

    public static void main(String[] args) {
        upgrade();
    }
    
    public static void upgrade() {
        rwl.readLock().lock();
        System.out.println("获取到了读锁");
        rwl.writeLock().lock();
        System.out.println("成功升级");
    }
    

    以上代码 "成功升级"不会打印。为啥不支持升级呢?锁升级的话就是将读锁升级成写锁,而读锁是共享锁,可以有多个线程持有的,这时候把读锁升级成写锁,那其他线程怎么办?这样就出现不同线程同时持有读写锁了。

问题:什么是自旋锁?它有什么好处和缺点?

答:自旋锁就是不停的去尝试获取锁,直到获取锁为止。实现的话就是通过不停的循环去获取锁,直到拿到锁为止
自旋锁和非自旋锁流程对比,自旋锁不会释放CPU时间片,不停的尝试获取锁,直到成功为止,而非自旋锁获取不到锁时就让线程休眠,释放CPU时间片,当别的线程释放锁红,再尝试获取锁。所以,非自旋锁和自旋锁最大的区别,就是如果它遇到拿不到锁的情况,它会把线程阻塞,直到被唤醒。而自旋锁会不停地尝试。

image.png

好处: 由于阻塞和唤醒线程都是需要很大的开销的,如果同步代码不复杂,那可能执行代码的开销还没有切换线程带来的开销大。所以这时候只要自旋等待一下,就可以避免上下文切换的开销,提高效率。

缺点 虽然避免了切换上下文的开销,但带来了新的开销,不停的自旋会占用CPU时间片做无用功,虽然一开始自旋的开销低,但是一直获取不到锁,这个开销会越来越大。

如何选择 自旋适用于并发程度不是特别高的,同步代码执行时间比较短的。这样自旋可以避免线程切换提高效率。
如果同步代码执行时间很长,线程拿到锁后,很久才释放锁,那么自旋就会白白浪费CPU资源。

问题:jvm 对锁做了哪些优化?

答:在JDK 1.6中HotSopt虚拟机对 synchronized 内置锁的性能进行了很多优化,包括自适应的自旋、锁消除、锁粗化、偏向锁、轻量级锁等。

  • 自适应自旋
    自旋锁有缺点就是当长时间拿不到锁,会一直尝试获取锁,浪费CPU资源。而自适应自旋就是解决这种问题的,它的自旋时间不是固定的,会根据最近自旋的成功率、失败率,以及当前锁拥有者状态多种因素决定。自旋锁变聪明了。

  • 锁消除
    如果编译器确定某些对象不可能被其他线程访问到,那么它一定是安全的,所以会把这样的锁给自动去除。

  • 锁粗化
    比如下面代码: public void lockCoarsening() { synchronized (this) { //do something } synchronized (this) { //do something } synchronized (this) { //do something } } 上面这种释放和重新获取锁是没有意义的,可以把同步区扩大 public void lockCoarsening() { synchronized (this) { //do something //do something //do something } } 但是锁粗化不适用于循环的场景,因为循环场景扩大同步区会导致持有锁的线程长时间运行,其他线程无法获取锁。

  • 偏向锁/轻量级锁/重量级锁 这三种锁是针对 synchronized 锁的状态的,通过在对象头的mark word来表明锁的状态。

    对于偏向锁,自始至终这个锁没有竞争,就没必要上锁,只要打个标记就行。一个对象被初始化后,如果没有任何线程来获取它的锁,它就是可偏向的。当有第一个线程来访问它尝试获取锁时,它就记录这个线程,如果后面尝试获取锁的线程正是这个偏向锁拥有者,就可以直接获取锁。

    轻量级锁指当锁原来是偏向锁的时候,被另一个线程所访问,那么偏向锁就会升级为轻量级锁,线程会通过自旋的方式获取锁,不会阻塞。

    重量级锁,当多个线程直接有实际竞争时,并且竞争时间比较长,此时偏向锁和轻量级锁就不能满足需求了,锁会膨胀成重量级锁。申请不到锁的线程会阻塞。

问题:什么是死锁?死锁的必要条件是什么?出现了死锁该怎么办?

答:死锁的定义:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象
image.png

死锁有四个必要条件:

  • 互斥,共享资源 X 和 Y 只能被一个线程占用
  • 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
  • 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
  • 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

如果线上出现了死锁,是保存 JVM 信息、日志等“案发现场”的数据,然后立刻重启服务,来尝试修复死锁。最好的办法还是规避死锁,想要规避死锁,只要破坏一个死锁的必要条件就不会有死锁问题了。

  • 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。(增加一个资源管理角色,想要一次性获取资源,就先获取资源管理者)
  • 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。(java Lock锁尝试获取,获取不到锁就放弃)
  • 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

问题:synchronized主要解决了什么问题?

答:synchronized主要解决了并发编程中两大核心问题,一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。

问题:以下代码synchronized使用是否正确?

class Account {
  // 账户余额  
  private Integer balance;
  // 账户密码
  private String password;
  // 取款
  void withdraw(Integer amt) {
    synchronized(balance) {
      if (this.balance > amt){
        this.balance -= amt;
      }
    }
  } 
  // 更改密码
  void updatePassword(String pw){
    synchronized(password) {
      this.password = pw;
    }
  } 
}

答:锁的基本原则应是私有的、不可变的、不可重用的。所以上面代码锁对象不正确。由于Ingeter会缓存-128~127范围数值,String有字符串常量池,所以 IntegerString 类型的对象在 JVM 里面是可能被重用的,除此之外,JVM 里可能被重用的对象还有 Boolean,那重用意味着什么呢?意味着你的锁可能被其他代码使用,如果其他代码 synchronized(你的锁),而且不释放,那你的程序就永远拿不到锁,这是隐藏的风险。

问题:在Java SDK中提供了Lock,为啥还要提供一个Semaphore?

答:Semaphore被翻译为信号量。信号量模型可以概括为:一个计数器、一个等待队列、三个方法(init,down,up)。 image.png 三个方法(都是原子性的):

  • init():设置计数器的初始值。
  • down():计数器的值减 1;如果此时计数器的值小于 0,则当前线程将被阻塞,否则当前线程可以继续执行。
  • up():计数器的值加 1;如果此时计数器的值小于或者等于 0,则唤醒等待队列中的一个线程,并将其从等待队列中移除。 LockSemaphore都可以实现互斥锁的效果,但是Semaphore还可以实现允许多个线程访问一个临界区。比较常见的需求有各种池化资源(连接池、对象池、线程池),同一时刻允许多个线程使用池化资源。这个是Lock不容易实现的。

Java并发容器相关

问题:为什么说 HashMap 不是线程安全的?

答:其实 HashMap 不安全的地方有很多。

  • HashMapput()方法有一行代码 modCount++,这句代码一看就是线程不安全的。

  • 扩容期间取值不准确,HashMap的扩容会新建一个空数组,并用旧的项填充到新数组中去,如果这个时候去获取值,可能会取到null值。以下代码会打印Exception in thread "main" java.lang.RuntimeException: HashMap is not thread safe. at top.xiaoduo.luka.api.activity.controller.HashMapNotSafe.main(HashMapNotSafe.java:26)

    public class HashMapNotSafe {
    
        public static void main(String[] args) {
            final Map<Integer, String> map = new HashMap<>();
    
            final Integer targetKey = 65535; // 65 535
            final String targetValue = "v";
            map.put(targetKey, targetValue);
    
            new Thread(() -> {
                IntStream.range(0, targetKey).forEach(key -> map.put(key, "someValue"));
            }).start();
    
            while (true) {
                if (null == map.get(targetKey)) {
                    throw new RuntimeException("HashMap is not thread safe.");
                }
            }
        }
    }
    
  • 同时put碰撞导致数据丢失,如果多个线程同时put添加元素,恰好两个putkey是一样的。它们发生碰撞,但是此时两个线程判断该位置是空的,那么把两个值写入同一个位置,最后丢失一个数据。

  • 无法保证可见性,如果一个线程给一个key放入一个新值,另一个线程取值的时候并不能保证取到的一定就是新值。

  • 死循环造成CPU100%,这个问题发生扩容时,可能会发生链表时环形。get的时候就会死循环。

问题:为什么ConcurrentHashMap 桶中超过8个才转成红黑树?

答:首先ConcurrentHashMap 是以数组+链表的形式的,当链表的长度大于8时就会把链表转成红黑树。
第一个问题就是为什么要转红黑树?
红黑树是一种平衡二叉树,所以查询效率很高,链表的时间复杂度为O(n),红黑树的时间复杂度为O(log(n)),所以为了提升性能会把链表转换成红黑树。
那为什么不一开始就使用红黑树呢?
因为红黑树中单个 TreeNode 需要占用的空间大约是普通 Node 的两倍,所以只有当包含足够多的 Nodes 时才会转成 TreeNodes,所以当节点数大于阈值时才转红黑树,节省空间。这个在Java源码中给了解释:

Because TreeNodes are about twice the size of regular nodes,
use them only when bins contain enough nodes to warrant use
(see TREEIFY_THRESHOLD). And when they become too small (due 
removal or resizing) they are converted back to plain bins.

链表长度达到 8 就转成红黑树,而当长度降到 6 就转换回去,这体现了时间和空间平衡的思想。
那么这个阈值 8 在Javadoc中也给了解释:

In usages with well-distributed user hashCodes, tree bins 
are rarely used.  Ideally, under random hashCodes, the 
frequency of nodes in bins follows a Poisson distribution 
(http://en.wikipedia.org/wiki/Poisson_distribution) with a 
parameter of about 0.5 on average for the default resizing 
threshold of 0.75, although with a large variance because 
of resizing granularity. Ignoring variance, the expected 
occurrences of list size k are (exp(-0.5* pow(0.5, k) / 
factorial(k)). The first values are:
 0:    0.60653066
 1:    0.30326533
 2:    0.07581633
 3:    0.01263606
 4:    0.00157952
 5:    0.00015795
 6:    0.00001316
 7:    0.00000094
 8:    0.00000006
 more: less than 1 in ten million

意思就是如果hashCode分布良好,计算结果离散比较均衡,那么红黑树很难被使用到。在理想的情况下,链表的长度符合泊松分布,链表长度达到8的概率小于千万分之一,所以一般来说链表不会转红黑树,如果你的代码中出现了链表转红黑树,那你需要考虑是不是hashCode方法不合适。

问题:比较ConcurrentHashMapHashtable的区别?

答:主要区别在于

  • 实现线程安全的方式不同
    Hashtable 数据结构也是采用数组+链表形式,它对关键方法都是使用了synchronized 同步关键字来达到线程安全的。
    ConcurrentHashMap分为Java 7 和 Java 8 的实现,Java 7 采用的是Segment分段锁来保证安全的,Segment继承ReentrantLock。Java 8中放弃了Segment设计,采用Node + CAS + synchronized保证线程安全。

  • 性能差异
    Hashtable由于使用synchronized,当线程数量增多,性能会急剧下降,因为每次只有一个线程可以操作对象,其他线程阻塞。而且还有上下文切换等开销。
    ConcurrentHashMap在Java 7的时候采用分段锁,它最大并发度就是分段的大小,默认16,比Hashtable效率高很多。而Java 8 采用 Node + CAS + synchronized方式,以put()方法为例,当通过hash计算出槽位,当发现槽位是空的,就使用CAS设置值,也就是说数组这一层是CAS无锁操作的。synchronized仅在链表或红黑树使用,也就是计算出的槽位发现已经有数据了(即出现Hash碰撞),那么将值挂载在链表或者红黑树时才使用synchronized。那么它的并发数就是数组的个数。 image.png

  • 迭代时修改不同
    Hashtable迭代时修改会报错,ConcurrentModificationException并发修改异常。主要是会检查modCountexpectedModCount是否相同,expectedModCount是在生产迭代器的时候生成且不能修改,而修改数据时会修改modCount值,那肯定就两个变量就不一致了。

    public T next() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        return nextElement();
    }
    

    ConcurrentHashMap在迭代期间修改数据不会抛出异常。

问题:分别说说ConcurrentHashMap1.7和1.8版本都是如何设计实现的?

答:

问题:请谈谈CopyOnWriteArrayList是什么?

答:

  • 定义
    CopyOnWriteArrayList是Java并发包中提供的一个并发容器,它是个线程安全且读操作无锁的ArrayList,写操作则通过创建底层数组的新副本来实现,是一种读写分离的并发策略,我们也可以称这种容器为"写时复制器"。CopyOnWriteArrayList允许并发读,读不加锁,最关键的是写入的时候不影响读取,因为写入的时候它是拷贝原数组在新的数组操作,压根就不影响原数组。只有多个写入才是同步的。我觉得它和数据库的多版本并发机制很像。

  • 添加元素源码 public boolean add(E e) { final ReentrantLock lock = this.lock; lock.lock(); try { Object[] elements = getArray(); int len = elements.length; Object[] newElements = Arrays.copyOf(elements, len + 1); newElements[len] = e; setArray(newElements); return true; } finally { lock.unlock(); } }

    • 使用 ReentrantLock 加锁
    • 拷贝原数组,并把长度加一,将原数据设置新数组中
    • 将原数组的引用指向新数组
    • 解锁
  • 优点

    • 读操作性能很高
    • 迭代时修改数据不会报并发修改异常
  • 缺点

    • 内存占用大,每次都需要复制一个新的数组,数据量大时,可能会频繁GC
    • 元素较多或者复杂时,复制开销很大
    • 无法保证实时性,写入的时候,读取的时老数据,只有写入完成才可以读取新数据
  • 适用场景
    读多写少场景,对实时性要求不是很高

并发工具

问题:AQS原理是什么?

答:我这里引用一个讲的比较清楚的博文,《一行一行源码分析清楚AbstractQueuedSynchronizer》

原子类

问题:什么是原子性?为什么会有原子性问题?怎么解决?

答:原子性是指保证一组操作要么全部成功,要么全部失败
所以原子性表象就是操作不可分割,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见

如果在执行一个或多个指令时只有一个线程执行不会被打扰是不是就不会出现原子性问题了?也就是执行一组操作时线程不会切换。
所以原子性问题的根源就是线程切换,而线程切换是依赖CPU中断的,所以禁止CPU中断就能解决原子性问题了,早期单核CPU确实如此,但是多核CPU下,即使禁止中断也不行,因为会有多个线程同时操作资源的情况。
所以,同一时刻只有一个线程执行这个条件才能解决原子性问题,我们称为互斥。可以依靠锁来完成。

问题:什么时原子类?有什么作用?

答:了解原子类之前先说什么是原子性,原子性是保证一组操作要么全部成功,要么全部失败。所以具有原子性的类就称为原子类,它可以原子性的添加、递增、递减等操作。
原子类的作用和锁类似,保证并发情况下线程安全
相对于锁,它的优势

  • 控制的粒度更细,原子变量可以把竞争范围控制在变量级别,一把情况下,锁得到粒度要大于原子变量的粒度。
  • 效率更高,原子类底层使用CAS,不会阻塞线程,除了高度竞争外,CAS的效率会高于锁的效率

问题:有哪些原子类?

答: image.png

  • 基本类型原子类
    AtomicIntegerAtomicLongAtomicBoolean //AtomicInteger 常用方法 public final int get() //获取当前的值 public final int getAndSet(int newValue) //获取当前的值,并设置新的值 public final int getAndIncrement() //获取当前的值,并自增 public final int getAndDecrement() //获取当前的值,并自减 public final int getAndAdd(int delta) //获取当前的值,并加上预期的值

  • 数组类型原子类
    AtomicIntegerArrayAtomicLongArrayAtomicReferenceArray

  • 引用类型原子类
    AtomicReferenceAtomicStampedReferenceAtomicMarkableReference 引用类型原子类和基本类型原子类差不多,引用类型原子类可以保证对象原子性

  • 升级类型原子类
    AtomicIntegerFieldUpdaterAtomicLongFieldUpdaterAtomicRerenceFieldUpdater
    可以将原先普通变量变成原子性,以AtomicIntegerFieldUpdater为例

    public class AtomicIntegerFieldUpdaterDemo implements Runnable {
    
        static class Score {
            volatile int score;
        }
        static Score computer ;
        static Score math;
        private AtomicIntegerFieldUpdater atomicIntegerFieldUpdater = AtomicIntegerFieldUpdater.newUpdater(Score.class, "score");
    
        @Override
        public void run() {
            for(int i=0; i < 1000; i++){
                // 普通变量 ++
                computer.score ++;
                // 使用升级类型原子类 ++
                atomicIntegerFieldUpdater.incrementAndGet(math);
            }
        }
    
        public static void main(String[] args) throws InterruptedException{
            computer = new Score();
            math = new Score();
            AtomicIntegerFieldUpdaterDemo r = new AtomicIntegerFieldUpdaterDemo();
            Thread t1 = new Thread(r);
            Thread t2 = new Thread(r);
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println("普通变量的结果:"+ computer.score);
            System.out.println("升级后的结果:"+ math.score);
        }
    }
    
    // 执行结果
    普通变量的结果:1998
    升级后的结果:2000
    

    那既然这些变量需要具备原子类,为何不一开始就设计成 AtomicInteger呢?

    • 历史原因,这个变量被声明普通变量而且被大量使用了,修改成本高
    • 如果这个变量只有一两个地方需要使用它原子性,其他很多地方不需要,就没必要把它设计成原子类,毕竟原子类比普通变量消耗资源。
  • Adder 累加器
    LongAdderDoubleAdder

  • Accumulator 积累器
    LongAccumulatorDoubleAccumulator

问题:对比 AtomicIntegersynchronized

答:

  • 相同点
    都可以保证线程安全

  • 不同点

    • 背后原理不同synchronized使用 monitor 监控器,先获取monitor ,执行完毕,释放monitor AtomicInteger利用的是CAS原理。
    • 使用的范围不同synchronized可以修饰方法,修饰代码块,也就是同步的范围更广,而AtomicInteger等原子类它仅仅是一个对象。
    • 性能区别,这也是乐观锁和悲观锁的区别。悲观锁开销是固定的,乐观锁一开始开销比较小,但是随着一直拿不到锁,不停的自旋开销会很大。所以这两个性能需要区分场景,竞争激烈并且同步区代码复杂的场景适合synchronized,竞争不激烈的使用原子类比较好。而且synchronized 优化后性能也是不错的。

问题:原子类底层用了CAS,那么CAS到底是什么?

答:CAS英文全称是Compare-And-Swap,意思是"比较并交换",它是一种思想、一种算法。CAS有三个操作数,内存值V、预期值A、修改值B,CAS的思路就是当预期值A和当前内存值V相同时,才将内存值修改为B,否则放弃本次修改,继续下次尝试。而"比较并交换"这个操作是由CPU一条指令完成的,所以它是具备原子性的。

问题:CAS有什么缺点?

答:有以下缺点:

  • CAS最大的缺点就是 ABA问题,ABA问题就是当前值从A变成B,再由B变成A,虽然这个值和最初的值是一样的,但其实这个值是被修改过的。而CAS判断的标准是当前的值和预期的值是否一致。所以CAS不能检测出在此期间值是不是被修改过,它只能检查出现在的值和最初的值是不是一样。解决办法就是加一个版本号,每次修改版本号加1,比较时除了比较值是否一致,还要比较版本号是否一致。
  • 自旋时间过长。CAS一般是配合循环来的甚至是死循环,在高并发场景下,一直无法修改成功,就会一直循环去尝试修改,耗费CPU资源。
  • 范围不能灵活控制,CAS只能修改某个变量,可能是基本类也可能是引用类型,但是它很难针对一组或者多个共享变量做CAS操作。

ThreadLocal

问题:ThreadLocal 一般用于什么场景?

答:两个典型场景:

  • ThreadLocal 用作保存每个线程独享的对象,为每个线程都创建一个副本,每个线程都只能修改自己所拥有的副本, 而不会影响其他线程的副本,这样就让原本在并发情况下,线程不安全的情况变成了线程安全的情况。
  • ThreadLocal 作用是提供线程内的局部变量,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度。 从线程的角度看,目标变量就像是线程的本地变量,这也是类名中Local所表达的意思。

问题:ThreadLocal是不是用来解决共享资源多线程访问的问题的?

答:其实不是,虽然ThreadLocal可以解决多线程情况下的线程安全问题,但是最重要的是这个资源它不是共享的,而是每个线程独有的,ThreadLocal会为每个线程复制一个副本,压根就没有出现资源的竞争。
如果放到ThreadLocal中的是一个static修饰的共享资源,那么ThreadLocal是不能保证资源线程安全的。

问题:ThreadLocalsynchronized 是什么关系?

答:虽然它们都可以解决线程安全的问题,但是它们的设计原理是不一样的。

  • ThreadLocal是通过让每个线程独享一个副本,从而避免了资源竞争。
  • synchronized主要是通过加锁限制临界区资源同一时刻只能一个线程访问来达到线程安全。 而且ThreadLocal还有另一种使用场景,就是通过ThreadLocal方便的在当前线程的任务地方拿到线程独立保存的信息,也就是使用ThreadLocal避免传递参数。

4.ThreadThreadLocalThreadLocalMap 三者之间的关系

答:一个Thread持有一个ThreadLocalMap,一个ThreadLocalMap可以存储多个ThreadLocal,每一个 ThreadLocal 都对应一个 value

image.png

ThreadThreadLocalMap关系 image.png

ThreadLocal类关系:

image.png

问题:ThreadLocal是如何做到为每一个线程维护变量的副本的?

答:在ThreadLocal类中有一个static声明的Map,用于存储每一个线程的变量副本,Map中元素的键为线程对象,而值对应线程的变量副本。

问题:ThreadLocal出现OOM问题

Java内存模型相关

问题:CPU多级缓存模型是什么样的?

问题:总线加锁机制和MESI缓存⼀致性协议?

问题:并发编程中为什么会有可见性、原子性、有序性问题?

答:

  • 可见性问题是由于缓存原因,因为CPU和主存中间存在多级缓存,多个CPU之间缓存数据不能及时同步导致的,JMM抽象成每个线程都有一个工作内存,而出现不同线程之间工作内存数据不可见问题。
  • 原子性是由于线程之间的切换导致的
  • 重排序是由于编译优化导致的

问题:volatile可以保证可⻅性、顺序性、原子性吗?

答:volatile是可以保证可见、有序性,以及可以保证原子性但又不能保证原子性。前面两点都没什么疑问,只是后面一句能保证原子性但又不能保证原子性该怎么理解,volatile可以保证读或者写每一个操作的原子性,比如读取一个变量这一个操作它是原子的,但是它不能保证多个原子的组合操作还是原子性的。比如 变量++操作就是典型的不是原子的。

问题:比较 JVM 内存结构 VS Java 内存模型?

答:

  • JVM内存结构和Java虚拟机运行时数据区有关。Java运行在Java虚拟机上,虚拟机会把所管理的内存划分为不同的数据区域,根据Java虚拟机规范中规定可以划分为6个区域:堆区、虚拟机栈、方法区、本地方法栈、程序计数器、运行常量池。
  • Java内存模型和Java并发编程有关。Java内存模型是一组规范,它主要是解决了CPU与多级缓存、处理器优化、指定重排序等导致的结果不可预期的问题。

问题:为啥需要JMM(Java内存模型)

答:早起是没有内存模型的概念的。所以程序最终执行的结果依赖于处理器,而各个处理器的规则还不一样,因此会出现同一段代码在不同处理器上执行得到不同结果,而且不同的JVM实现,也会带来不同的翻译结果。
所以Java需要一个标准告诉开发者、编译器和JVM工程师达成一致。这个标准就是JMM。

问题:JMM是什么?

答:JMM是和多线程相关的一组规范,需要JVM的实现遵守JMM规范,JMM与处理器、缓存、并发、编译器有关(Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法,具体来说,这些方法包括了volatilesynchronized final三个关键字,以及六项Happens-Before规则)。它解决了 CPU 多级缓存、处理器优化、指令重排等导致的结果不可预期的问题。Java线程之间的通信由Java内存模型控制,JMM决定了一个线程对共享变量的写入何时对另一个线程可见。

问题:什么是指定重排序?

答:我们写的Java程序中的语句在运行时顺序不一定和我们写的代码顺序一致,因为编译器、JVM、CPU都有可能出于优化的目的对指令的顺序做调整,这就是重排序。

问题:那重排序有什么好处,为什么要重排序?

答:重排序的好处就是 提高处理器速度。举个例子:

image.png

image.png Load是从主存读取数据,Store是把数据写入主存。由于重排序前需要对 a 做了两次 LoadStore,而重排序后对 a 只是做了一次LoadStore。这样提升整体运行速度。不过重排序它不会任意排序,它要保证不改变单线程内的语义。

问题:在Java中哪些是具备原子操作的?

答:以下这些都具备原子操作。

  • 除了longdouble之外的基本类型(intbytebooleanshortcharfloat)的读/写操作,都是天然具备原子性的。
  • 所有引用reference的读/写操作。
  • 加了volatile后变量的读/写操作(包含longdouble
  • java.concurrent.Atomic 包中的一部分类的一部分方法是具备原子性的,比如 AtomicIntegerincrementAndGet 方法。

但是要注意 :原子操作 + 原子操作 != 原子操作。比如使用 volatile修饰的int变量ii的取值和赋值分别都是原子操作,如果取值后再自增然后赋值就不是原子操作了。

问题:为啥longdoouble的读写不是原子性的?

答:longdouble的值都需要占用64位的内存空间,而对于64位值的写入,可以分为两个32位的操作进行。这样一个赋值操作就被拆成低32位和高32位的两个操作。多线程下就可能出现错误的值。所以使用volatile修饰那么读/写就是原子操作了。但是实际开发中一般不会使用volatile来修饰longdouble类型,因为各个虚拟机的实现中都会把它作为原子操作来对待。

问题:为什么会出现内存可见性问题

答案:
要说这个问题就涉及现代CPU架构,如下图

image.png 因为存在CPU缓存一致性协议,多个CPU之间的缓存不会出现不同步问题。但是,缓存一致性协议对性能有损耗,所以CPU设计者们在此基础上又进行了优化。比如:在计算单元和L1之间加了Store Buffer、Load Buffer,而Store Buffer和L1之间是不同步的。 image.png 而映射到Java中,Java内存模型对CPU多级缓存做了抽象 image.png JMM 有以下规定:

  1. 所有的变量都存储在主内存中,同时每个线程拥有自己独立的工作内存,而工作内存中的变量的内容是主内存中该变量的拷贝;

  2. 线程不能直接读 / 写主内存中的变量,但可以操作自己工作内存中的变量,然后再同步到主内存中,这样,其他线程就可以看到本次修改;

  3. 主内存是由多个线程所共享的,但线程间不共享各自的工作内存,如果线程间需要通信,则必须借助主内存中转来完成。

问题:什么是happens-before关系?

答:happens-before关系是用来描述和可见性相关的问题的:如果第一个操作 happens-before第二个操作,那么我们就说第一个操作对于第二个操作一定是可见的。

程序设计

问题:异步和同步有什么区别?Dubbo是如何实现将异步转同步,你能设计这个程序吗?

答:异步和同步之间的区别,通俗来讲就是调用方是否需要等待结果,需要等待结果就是同步,不需要等待结果就是异步
Dubbo 是知名的RPC框架,TCP协议层面,发送完RPC请求后,线程是不会等待RPC的响应结果的。但是我们在使用RPC框架时大多数都是同步的,那是因为框架把异步转同步了。

// 创建锁与条件变量
private final Lock lock 
    = new ReentrantLock();
private final Condition done 
    = lock.newCondition();
 
// 线程调用 get()方法等待RPC返回结果
Object get(int timeout){
  long start = System.nanoTime();
  lock.lock();
  try {
	while (!isDone()) {
          // 没有返回结果 等待
	  done.await(timeout);
      long cur=System.nanoTime();
	  if (isDone() || 
          cur-start > timeout){
          // 等待超时
	    break;
	  }
	}
  } finally {
	lock.unlock();
  }
  if (!isDone()) {
	throw new TimeoutException();
  }
  return returnFromResponse();
}
// RPC 结果是否已经返回
boolean isDone() {
  return response != null;
}
// RPC 结果返回时调用该方法   
private void doReceived(Response res) {
  lock.lock();
  try {
    response = res;
    if (done != null) {
    // 调用 signal 来通知调用线程
      done.signal();
    }
  } finally {
    lock.unlock();
  }
}

问题:能否手写一个可重入的自旋锁?

public class ReentrantSpinLock {


    private AtomicReference<Thread> owner = new AtomicReference<>();

    // 可重入次数
    private int count = 0;

    // 加锁
    public void lock() {
        Thread current = Thread.currentThread();
        if (owner.get() == current) {
            count++;
            return;
        }
        while (!owner.compareAndSet(null, current)) {
            System.out.println("--我在自旋--");
        }
    }

    //解锁
    public void unLock() {
        Thread current = Thread.currentThread();
        //只有持有锁的线程才能解锁
        if (owner.get() == current) {
            if (count > 0) {
                count--;
            } else {
                //此处无需CAS操作,因为没有竞争,因为只有线程持有者才能解锁
                owner.set(null);
            }
        }
    }

    public static void main(String[] args) {
        ReentrantSpinLock spinLock = new ReentrantSpinLock();
        Runnable runnable = () -> {
            System.out.println(Thread.currentThread().getName() + "开始尝试获取自旋锁");
            spinLock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + "获取到了自旋锁");
                Thread.sleep(4000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                spinLock.unLock();
                System.out.println(Thread.currentThread().getName() + "释放了了自旋锁");
            }
        };
        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        thread1.start();
        thread2.start();
    }
}

问题:如何使用Semaphore快速实现一个限流器?

答:

public class SemaphoreDemo {

    static class Link {
    }

    static class ObjPool<T, R> {

        final List<T> pool;

        final Semaphore semaphore;

        ObjPool(int size, T t) {
            pool = new Vector<>(size);
            for (int i = 0; i < size; i++) {
                pool.add(t);
            }
            semaphore = new Semaphore(size);
        }

        public R exec(Function<T, R> func) throws Exception {
            T t = null;
            semaphore.acquire();
            try {
                System.out.println(Thread.currentThread().getName() + "---------争夺锁--------");

                t = pool.remove(0);
                System.out.println(Thread.currentThread().getName() + " 拿到锁执行");
                return func.apply(t);
            } finally {
                pool.add(t);
                semaphore.release();
            }
        }
    }

    public static void main(String[] args) {
        ObjPool objPool = new ObjPool(5, new Link());
        for (int i = 0; i < 30; i++) {
            new Thread(() -> {
                try {
                    objPool.exec(t -> t.toString());
                } catch (Exception e) {
                }
            }).start();
        }
    }
}

源码剖析