一文让你彻底了解多线程

伙伴们很抱歉,因为最近需要粉丝突破1000,所以很多文章都设置了仅粉丝可见,如果大家看完这篇文章感觉对自己没啥帮助,可以在取消关注!!!

本文篇幅很长,建议大家分段阅读,如果你准备面试,那么就请你一定要全文理解并记忆,如果你希望通过并发编程提升系统性能,那么你在本文就会了解到CAS、AQS、Thread的使用以及相关注意事项。

如果你感觉对你有帮助请记得点赞、关注加收藏哦!!!


文章目录

  • 1.创建线程有几种方式?
    • 2.1 定义Thread类的子类,并重写该类的run方法
    • 2.2 定义Runnable接口的实现类,并重写该接口的run()方法
    • 2.3 定义Callable接口的实现类,并重写该接口的call()方法
    • 2.4 线程池的方式
  • 3. start()方法和run()方法的区别
  • 4. 线程和进程的区别
  • 5. Runnable和 Callable有什么区别?
  • 6. 聊聊volatile作用,原理
  • 7. 说说并发与并行的区别?
  • 8.synchronized 的实现原理以及锁优化?
    • 8.1 monitorenter、monitorexit、ACC_SYNCHRONIZED
    • 8.2 monitor监视器
    • 8.3 Java Monitor 的工作机理
    • 8.4 对象与monitor关联
  • 9. 线程有哪些状态?
  • 10. synchronized和ReentrantLock的区别?
  • 11. wait(),notify()和suspend(),resume()之间的区别
  • 12. CAS?CAS 有什么缺陷,如何解决?
    • **CAS有什么缺陷?**
      • ABA 问题
      • 循环时间长开销
      • 只能保证一个变量的原子操作
  • 13. 说说CountDownLatch与CyclicBarrier 区别
  • 14. 什么是多线程环境下的伪共享
    • 14.1 什么是伪共享?
    • 14.2 如何解决伪共享问题
  • 15. Fork/Join框架的理解
        • 分而治之
        • 工作窃取算法
  • 16. 聊聊ThreadLocal原理?
    • 16.1 TreadLocal为什么会导致内存泄漏呢?
      • 弱引用导致的内存泄漏
    • 16.2 key是弱引用,GC回收会影响ThreadLocal的正常工作嘛?
    • 16.3 ThreadLocal内存泄漏的demo
    • 16.4 为什么ThreadLocalMap 的 key是弱引用,设计理念是?
    • 16.5 如何保证父子线程间的共享ThreadLocal数据
  • 17.如何保证多线程下 i++ 结果正确?
  • 18.如何检测死锁?怎么预防死锁?死锁四个必要条件
      • 如何预防死锁?
  • 19.如果线程过多,会怎样?
  • 20.聊聊happens-before原则
  • 21.如何实现两个线程间共享数据
  • 22.LockSupport作用是?
  • 23.线程池如何调优,如何确认最佳线程数?
  • 24.为什么要用线程池?
  • 25.Java的线程池执行原理
  • 26.聊聊线程池的核心参数
  • 27.当提交新任务时,异常如何处理?
  • 28.AQS组件,实现原理
    • 28.1 state 状态的维护
    • 28.2 CLH队列
    • 28.3 ConditionObject通知
    • 28.4 模板方法设计模式
    • 28.5 独占与共享模式
    • 28.6 自定义同步器
  • 29.Semaphore原理
    • 29.1 Semaphore使用demo
  • 30.synchronized做了哪些优化?什么是偏向锁?什么是自旋锁?锁租化?
  • 31.什么是上下文切换?
  • 32.为什么wait(),notify(),notifyAll()在对象中,而不在Thread类中
  • 33.线程池中 submit()和 execute()方法有什么区别?
  • 34.AtomicInteger 的原理?
  • 35. Java中用到的线程调度算法是什么?
  • 36.shutdown() 和 shutdownNow()的区别
  • 37.说说几种常见的线程池及使用场景?
  • 38.什么是FutureTask
  • 39. java中interrupt(),interrupted()和isInterrupted()的区别
  • 40.有三个线程T1,T2,T3,怎么确保它们按顺序执行
  • 41.有哪些阻塞队列
  • 42.Java中ConcurrentHashMap的并发度是什么?
  • 43. Java线程有哪些常用的调度方法?
    • 44.ReentrantLock的加锁原理
    • 44.1 ReentrantLock使用的模板
    • 44.2 什么是非公平锁,什么是公平锁?
    • 44.3 lock()加锁流程
  • 45.线程间的通讯方式
    • 45.1 volatile和synchronized关键字
    • 45.2 等待/通知机制
    • 45.3 管道输入/输出流
    • 45.4 join()方法
  • 46.写出3条你遵循的多线程最佳实践
  • 47.为什么阿里发布的 Java开发手册中强制线程池不允许使用 Executors 去创建?
  • 48.细数线程池的10个坑
    • 48.1 线程池默认使用无界队列,任务过多导致OOM
    • 48.2 线程池创建线程过多,导致OOM
    • 48.3 共享线程池,次要逻辑拖垮主要逻辑
    • 48.4 线程池拒绝策略的坑,使用不当导致阻塞
    • 48.5 Spring内部线程池的坑
    • 48.6 使用线程池时,没有自定义命名
    • 48.7 线程池参数设置不合理
    • 48.8 线程池异常处理的坑
    • 48.9 线程池使用完毕后,忘记关闭
    • 48.10 ThreadLocal与线程池搭配,线程复用,导致信息错乱
  • 49.Thread和Runable的区别
  • 50.CompletableFuture默认线程池踩坑,请务必自定义线程池
  • 51.线程池的生命周期
  • 52 sleep与wait有什么区别
    • 52.1 原理不同
    • 52.2 对锁的处理机制不同
    • 52.3使用区域不同

我们使用多线程就是因为: 在正确的场景下,设置恰当数目的线程,可以用来程提高序的运行速率。更专业点讲,就是充分地利用CPU和I/O的利用率,提升程序运行速率

当然,有利就有弊,多线程场景下,我们要保证线程安全,就需要考虑加锁。加锁如果不恰当,就很很耗性能。

1.创建线程有几种方式?

一文让你彻底了解多线程_第1张图片

Java中创建线程主要有以下这几种方式:

  • 定义Thread类的子类,并重写该类的run方法
  • 定义Runnable接口的实现类,并重写该接口的run()方法
  • 定义Callable接口的实现类,并重写该接口的call()方法,一般配合Future使用
  • 线程池的方式

2.1 定义Thread类的子类,并重写该类的run方法

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("请大家多多关注");
    }
}

public class Test {
    public static void main(String[] args) {
        Thread thread = new MyThread();
        thread.start();
    }
}

2.2 定义Runnable接口的实现类,并重写该接口的run()方法

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Runable:请大家多多关注");
    }
}
public class Test {
    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.start();
    }
}

2.3 定义Callable接口的实现类,并重写该接口的call()方法

如果想要执行的线程有返回,可以使用Callable。

class MyThreadCallable implements Callable {
    @Override
    public String call()throws Exception {
        return "Callable:请大家多多关注";
    }
}
public class Test {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        MyThreadCallable mc = new MyThreadCallable();
        FutureTask<Integer> ft = new FutureTask<>(mc);
        Thread thread = new Thread(ft);
        thread.start();
        System.out.println(ft.get());
    }
}

2.4 线程池的方式

线程池内容还是比较多的,如果想深入了解的话可以移步到这篇文章:《线程池很难吗?带你深入浅出线程池》
日常开发中,我们一般都是用线程池的方式执行异步任务。

public class Test {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ThreadPoolExecutor executorOne = new ThreadPoolExecutor(5, 5, 1,
                TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(20), new CustomizableThreadFactory("Tianluo-Thread-pool"));
        executorOne.execute(() -> {
            System.out.println("线程池:麻烦各位看官关注一下呗,来都来啦");
        });

        //关闭线程池
        executorOne.shutdown();
    }
}

3. start()方法和run()方法的区别

其实start和run的主要区别如下:

  • start方法可以启动一个新线程,run方法只是类的一个普通方法而已,如果直接调用run方法,程序中依然只有主线程这一个线程。
  • start方法实现了多线程,而run方法没有实现多线程。
  • start不能被重复调用,而run方法可以。
  • start方法中的run代码可以不执行完,就继续执行下面的代码,也就是说进行了线程切换。
    然而,如果直接调用run方法,就必须等待其代码全部执行完才能继续执行下面的代码。

我们通过代码看一下:

public class Test {
    public static void main(String[] args){
        Thread t=new Thread(){
            public void run(){
                pong();
            }
        };
        t.start();
        t.run();
        t.run();
        System.out.println("好的,马上去关注:NineSun"+ Thread.currentThread().getName());
    }

    static void pong(){
        System.out.println("麻烦大家给个关注呗:"+ Thread.currentThread().getName());
    }
}

一文让你彻底了解多线程_第2张图片

4. 线程和进程的区别

  • 进程是运行中的应用程序,线程是进程的内部的一个执行序列
  • 进程是资源分配的最小单位,线程是CPU调度的最小单位。
  • 一个进程可以有多个线程。线程又叫做轻量级进程,多个线程共享进程的资源
  • 进程间切换代价大,线程间切换代价小
  • 进程拥有资源多,线程拥有资源少地址
  • 进程是存在地址空间的,而线程本身无地址空间,线程的地址空间是包含在进程中的

举个例子:

你打开QQ,开了一个进程;打开了迅雷,也开了一个进程。

在QQ的这个进程里,传输文字开一个线程、传输语音开了一个线程、弹出对话框又开了一个线程。

所以运行某个软件,相当于开了一个进程。在这个软件运行的过程里(在这个进程里),多个工作支撑的完成QQ的运行,那么这“多个工作”分别有一个线程。

所以一个进程管着多个线程。

通俗的讲:“进程是爹妈,管着众多的线程儿子”…

5. Runnable和 Callable有什么区别?

  • Runnable接口中的run()方法没有返回值,是void类型,它做的事情只是纯粹地去执行run()方法中的代码而已;
  • Callable接口中的call()方法是有返回值的,是一个泛型。它一般配合Future、FutureTask一起使用,用来获取异步执行的结果
  • Callable接口call()方法允许抛出异常;而Runnable接口run()方法不能继续上抛异常;

它俩的API如下:

@FunctionalInterface
public interface Callable<V> {
    /**
     * 支持泛型V,有返回值,允许抛出异常
     */
    V call() throws Exception;
}

@FunctionalInterface
public interface Runnable {
    /**
     *  没有返回值,不能继续上抛异常
     */
    public abstract void run();
}

我们通过下面的代码在深入了解一下:

public class Test {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(5);

        Callable<String> callable =new Callable<String>() {
            @Override
            public String call() throws Exception {
                return "你好,callable";
            }
        };

        //支持泛型
        Future<String> futureCallable = executorService.submit(callable);

        try {
            System.out.println("获取callable的返回结果:"+futureCallable.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("你好呀,runnable");
            }
        };

        Future<?> futureRunnable = executorService.submit(runnable);
        try {
            System.out.println("获取runnable的返回结果:"+futureRunnable.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
        executorService.shutdown();

    }
}

一文让你彻底了解多线程_第3张图片

6. 聊聊volatile作用,原理

volatile关键字是Java虚拟机提供的的最轻量级的同步机制。它作为一个修饰符,用来修饰变量。它保证变量对所有线程可见性禁止指令重排,但是不保证原子性

我们先来一起回忆下java内存模型(jmm):

Java虚拟机规范试图定义一种Java内存模型,来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台上都能达到一致的内存访问效果。

Java内存模型规定所有的变量都是存在主内存当中,每个线程都有自己的工作内存。这里的变量包括实例变量和静态变量,但是不包括局部变量,因为局部变量是线程私有的。

JMM的模型如下所示:
一文让你彻底了解多线程_第4张图片

线程的工作内存保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接操作主内存。并且每个线程不能访问其他线程的工作内存

volatile变量,保证新值能立即同步回主内存,以及每次使用前立即从主内存刷新,所以我们说volatile保证了多线程操作变量的可见性

volatile保证可见性和禁止指令重排,都跟内存屏障有关。我们来看一段volatile使用的demo代码:

class Singleton {
    private volatile static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

如果对设计模式有些许了解的伙伴们,很容易就发现上面的代码其实是很经典的单例模式,而且是懒汉模式,我们知道单例模式下会出现线程安全的问题,我们通过volatile和syschronized来保证线程的安全。

编译后,对比有volatile关键字和没有volatile关键字时所生成的汇编代码,发现有volatile关键字修饰时,会多出一个lock addl $0x0,(%esp),即多出一个lock前缀指令,lock指令相当于一个内存屏障

lock指令相当于一个内存屏障,它保证以下这几点:

  • 重排序时不能把后面的指令重排序到内存屏障之前的位置
  • 将本处理器的缓存写入内存
  • 如果是写入动作,会导致其他处理器中对应的缓存无效。

第2点和第3点就是保证volatile保证可见性的体现嘛,第1点就是禁止指令重排的体现。

内存屏障四大分类:(Load 代表读取指令,Store代表写入指令)
一文让你彻底了解多线程_第5张图片

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

内存屏障保证前面的指令先执行,所以这就保证了禁止了指令重排啦,同时内存屏障保证缓存写入内存和其他处理器缓存失效,这也就保证了可见性

7. 说说并发与并行的区别?

并发和并行最开始都是操作系统中的概念,表示的是CPU执行多个任务的方式。

顺序:上一个开始执行的任务完成后,当前任务才能开始执行

并发:无论上一个开始执行的任务是否完成,当前任务都可以开始执行
(即 A B 顺序执行的话,A 一定会比 B 先完成,而并发执行则不一定。)

串行:有一个任务执行单元,从物理上就只能一个任务、一个任务地执行

并行:有多个任务执行单元,从物理上就可以多个任务一起执行
(即在任意时间点上,串行执行时必然只有一个任务在执行,而并行则不一定。)

有个很有意思的例子,可以帮助大家理解一下:

你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。

你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。

吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。

并发的关键是你有处理多个任务的能力,不一定要同时
并行的关键是你有同时处理多个任务的能力。所以我认为它们最关键的点就是:是否是同时

8.synchronized 的实现原理以及锁优化?

在我们进行并发安全控制的时候,有轻量级锁:volatile,还有无锁的cas,还有比较重量级的synchronized以及RetreentLock,本节中我对synchronized里面的知识点挑一些一些重点的讲解一下,如果你想更加深入的了解可以移步至:《一文带你彻底了解synchronized 和 Lock》

synchronized是Java中的关键字,是一种同步锁。synchronized关键字可以作用于方法或者代码块。

一般面试时。可以这么回答:
一文让你彻底了解多线程_第6张图片

8.1 monitorenter、monitorexit、ACC_SYNCHRONIZED

如果synchronized作用于代码块,反编译可以看到两个指令:monitorentermonitorexit,JVM使用monitorenter和monitorexit两个指令实现同步;

如果作用synchronized作用于方法,反编译可以看到ACCSYNCHRONIZED标记,JVM通过在方法访问标识符(flags)中加入ACCSYNCHRONIZED来实现同步功能。

同步代码块是通过monitorenter和monitorexit来实现,当线程执行到monitorenter的时候要先获得monitor锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁

同步方法是通过中设置ACCSYNCHRONIZED标志来实现,当线程执行有ACCSYNCHRONI标志的方法,需要获得monitor锁。每个对象都与一个monitor相关联,线程可以占有或者释放monitor。

8.2 monitor监视器

monitor是什么呢?操作系统的管程(monitors)是概念原理,ObjectMonitor是它的原理实现。
一文让你彻底了解多线程_第7张图片

在Java虚拟机(HotSpot)中,Monitor(管程)是由ObjectMonitor实现的,其主要数据结构如下:
一文让你彻底了解多线程_第8张图片

ObjectMonitor中几个关键字段的含义如图所示:
一文让你彻底了解多线程_第9张图片

8.3 Java Monitor 的工作机理

一文让你彻底了解多线程_第10张图片

  • 想要获取monitor的线程,首先会进入_EntryList队列。
  • 当某个线程获取到对象的monitor后,进入Owner区域,设置为当前线程,同时计数器count加1。
  • 如果线程调用了wait()方法,则会进入WaitSet队列。它会释放monitor锁,即将owner赋值为null,count自减1,进入WaitSet队列阻塞等待。
  • 如果其他线程调用 notify() / notifyAll() ,会唤醒WaitSet中的某个线程,该线程再次尝试获取monitor锁,成功即进入Owner区域。
  • 同步方法执行完毕了,线程退出临界区,会将monitor的owner设为null,并释放监视锁。

8.4 对象与monitor关联

一文让你彻底了解多线程_第11张图片

一文让你彻底了解多线程_第12张图片
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对象填充(Padding)。

对象头主要包括两部分数据:

  • Mark Word(标记字段)
  • Class Pointer(类型指针)。

Mark Word 是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
一文让你彻底了解多线程_第13张图片

重量级锁,指向互斥量的指针。

其实synchronized是重量级锁,也就是说Synchronized的对象锁,Mark Word锁标识位为10,其中指针指向的是Monitor对象的起始地址。

9. 线程有哪些状态?

线程有6个状态,分别是:New(创建 ), Runnable(就绪+运行), Blocked(阻塞), Waiting(等待), Timed_Waiting(超时等待), Terminated(死亡)。

一文让你彻底了解多线程_第14张图片

New:线程对象创建之后、但还没有调用start()方法,就是这个状态。

public class ThreadTest {

    public static void main(String[] args) {
        Thread thread = new Thread();
        System.out.println(thread.getState());
    }
}

//运行结果:
NEW

Runnable:它包括就绪(ready)运行中(running)两种状态。

如果调用start方法,线程就会进入Runnable状态。它表示我这个线程可以被执行啦(此时相当于ready状态),如果这个线程被调度器分配了CPU时间,那么就可以被执行(此时处于running状态)。

public class ThreadTest {

    public static void main(String[] args) {
        Thread thread = new Thread();
        thread.start();
        System.out.println(thread.getState());
    }
}
//运行结果:
RUNNABLE

Blocked: 阻塞的(被同步锁或者IO锁阻塞)。表示线程阻塞于锁,线程阻塞在进入synchronized关键字修饰的方法或代码块(等待获取锁)时的状态。比如前面有一个临界区的代码需要执行,那么线程就需要等待,它就会进入这个状态。它一般是从RUNNABLE状态转化过来的。如果线程获取到锁,它将变成RUNNABLE状态。

Thread t = new Thread(new Runnable {
    void run() {
        synchronized (lock) { // 阻塞于这里,变为Blocked状态
            // dothings
        } 
    }
});
System.out.println(t.getState()); //新建之前,还没开始调用start方法,处于New状态

t.start(); //调用start方法,就会进入Runnable状态
System.out.println(t.getState());
//运行结果:
NEW
RUNNABLE

WAITING: 永久等待状态,进入该状态的线程需要等待其他线程做出一些特定动作(比如通知)。处于该状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。一般Object.wait。

Thread t = new Thread(new Runnable {
    void run() {
        synchronized (lock) { // Blocked
            // dothings
            while (!condition) {
                lock.wait(); // into Waiting
            }
        } 
    }
});
t.getState(); // New

t.start(); // Runnable

TIMED_WATING: 等待指定的时间重新被唤醒的状态。
有一个计时器在里面计算的,最常见就是使用Thread.sleep方法触发,触发后,线程就进入了Timed_waiting状态,随后会由计时器触发,再进入Runnable状态抢占cpu资源。

Thread t = new Thread(new Runnable {
    void run() {
        Thread.sleep(1000); // Timed_waiting
    }
});
t.getState(); // New
t.start(); // Runnable

终止(TERMINATED):表示该线程已经执行完成。

10. synchronized和ReentrantLock的区别?

  • Synchronized是依赖于JVM实现的,而ReenTrantLock是API实现的。
  • 在Synchronized优化以前,synchronized的性能是比ReenTrantLock差很多的,但是自从Synchronized引入了偏向锁,轻量级锁(自旋锁)后,两者性能就差不多了。
  • Synchronized的使用比较方便简洁,它由编译器去保证锁的加锁和释放。而ReenTrantLock需要手工声明来加锁和释放锁,最好在finally中声明释放锁。
  • ReentrantLock可以指定是公平锁还是⾮公平锁。⽽synchronized只能是⾮公平锁。
  • ReentrantLock可响应中断、可轮回,而Synchronized是不可以响应中断的

11. wait(),notify()和suspend(),resume()之间的区别

  • wait()方法使得线程进入阻塞等待状态,并且释放锁
  • notify()唤醒一个处于等待状态的线程,它一般跟wait()方法配套使用。
  • suspend()使得线程进入阻塞状态,并且不会自动恢复,必须对应的resume()被调用,才能使得线程重新进入可执行状态。suspend()方法很容易引起死锁问题。
  • resume()方法跟suspend()方法配套使用。
    suspend()不建议使用,因为suspend()方法在调用后,线程不会释放已经占有的资源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。

12. CAS?CAS 有什么缺陷,如何解决?

在高并发的业务场景下,线程安全问题是必须考虑的,在JDK5之前,可以通过synchronized或Lock来保证同步,从而达到线程安全的目的。

但synchronized或Lock方案属于互斥锁的方案,比较重量级,加锁、释放锁都会引起性能损耗问题。

而在某些场景下,我们是可以通过JUC提供的CAS机制实现无锁的解决方案,或者说是它基于类似于乐观锁的方案,来达到非阻塞同步的方式保证线程安全。

CAS是Compare And Swap的缩写,直译就是比较并交换。

CAS是现代CPU广泛支持的一种对内存中共享数据进行操作的一种特殊指令,这个指令会对内存中的共享数据做原子的读写操作。其作用是让CPU比较内存中某个值是否和预期的值相同,如果相同则将这个值更新为新值,不相同则不做更新。

本质上来讲CAS是一种无锁的解决方案,也是一种基于乐观锁的操作,可以保证在多线程并发中保障共享资源的原子性操作,相对于synchronized或Lock来说,是一种轻量级的实现方案。

Java中大量使用了CAS机制来实现多线程下数据更新的原子化操作,比如AtomicInteger、CurrentHashMap当中都有CAS的应用。

但Java中并没有直接实现CAS,CAS相关的实现是借助C/C++调用CPU指令来实现的,效率很高,但Java代码需通过JNI才能调用。比如,Unsafe类提供的CAS方法(如compareAndSwapXXX)底层实现即为CPU指令cmpxchg。
一文让你彻底了解多线程_第15张图片

在上图中涉及到三个值的比较和操作:修改之前获取的(待修改)值A,业务逻辑计算的新值B,以及待修改值对应的内存位置的C。

整个处理流程中,假设内存中存在一个变量i,它在内存中对应的值是A(第一次读取),此时经过业务处理之后,要把它更新成B,那么在更新之前会再读取一下i现在的值C,如果在业务处理的过程中i的值并没有发生变化,也就是A和C相同,才会把i更新(交换)为新值B。

如果A和C不相同,那说明在业务计算时,i的值发生了变化,则不更新(交换)成B。最后,CPU会将旧的数值返回。而上述的一系列操作由CPU指令来保证是原子的。

在上述路程中,我们可以很清晰的看到乐观锁的思路,而且这期间并没有使用到锁。因此,相对于synchronized等悲观锁的实现,效率要高非常多。

CAS有什么缺陷?

一文让你彻底了解多线程_第16张图片

ABA 问题

虽然使用CAS可以实现非阻塞式的原子性操作,但是会产生ABA问题,ABA问题出现的基本流程:

  • 进程P1在共享变量中读到值为A;
  • P1被抢占了,进程P2执行;
  • P2把共享变量里的值从A改成了B,再改回到A,此时被P1抢占;
  • P1回来看到共享变量里的值没有被改变,于是继续执行;

虽然P1以为变量值没有改变,继续执行了,但是这个会引发一些潜在的问题。ABA问题最容易发生在lock free的算法中的,CAS首当其冲,因为CAS判断的是指针的地址。如果这个地址被重用了呢,问题就很大了(地址被重用是很经常发生的,一个内存分配后释放了,再分配,很有可能还是原来的地址)。
ABA问题的解决思路就是使用版本号:在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A->B->A就会变成1A->2B->3A。

另外,从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。

这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

AtomicInteger是java.util.concurrent.atomic 包下的一个原子类,该包下还有AtomicBoolean,AtomicLong,AtomicLongArray, AtomicReference等原子类,主要用于在高并发环境下,保证线程安全。

循环时间长开销

自旋CAS,如果一直循环执行,一直不成功,会给CPU带来非常大的执行开销。很多时候,CAS思想体现,是有个自旋次数的,就是为了避开这个耗时问题~

只能保证一个变量的原子操作

CAS 保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS 目前无法直接保证操作的原子性的。可以通过这两个方式解决这个问题:

  • 1.使用互斥锁来保证原子性;
  • 2.将多个变量封装成对象,通过AtomicReference来保证原子性。

13. 说说CountDownLatch与CyclicBarrier 区别

CountDownLatch和CyclicBarrier都用于让线程等待,达到一定条件时再运行。主要区别是:

  • CountDownLatch:一个或者多个线程,等待其他多个线程完成某件事情之后才能执行;
  • CyclicBarrier:多个线程互相等待,直到到达同一个同步点,再继续一起执行。

一文让你彻底了解多线程_第17张图片

举个例子帮助大家理解一下:

CountDownLatch:假设老师跟同学约定周末在公园门口集合,等人齐了再发门票。那么,发门票(这个主线程),需要等各位同学都到齐(多个其他线程都完成),才能执行。

CyclicBarrier:多名短跑运动员要开始田径比赛,只有等所有运动员准备好,裁判才会鸣枪开始,这时候所有的运动员才会疾步如飞。

14. 什么是多线程环境下的伪共享

14.1 什么是伪共享?

CPU的缓存是以缓存行(cache line)为单位进行缓存的,当多个线程修改相互独立的变量,而这些变量又处于同一个缓存行时就会影响彼此的性能。这就是伪共享

现代计算机计算模型:
一文让你彻底了解多线程_第18张图片

  • CPU执行速度比内存速度快好几个数量级,为了提高执行效率,现代计算机模型演变出CPU、缓存(L1,L2,L3),内存的模型。
  • CPU执行运算时,如先从L1缓存查询数据,找不到再去L2缓存找,依次类推,直到在内存获取到数据。
  • 为了避免频繁从内存获取数据,聪明的科学家设计出缓存行,缓存行大小为64字节

也正是因为缓存行的存在,就导致了伪共享问题,如图所示:
一文让你彻底了解多线程_第19张图片

假设数据a、b被加载到同一个缓存行。

  • 当线程1修改了a的值,这时候CPU1就会通知其他CPU核,当前缓存行(Cache line)已经失效。
  • 这时候,如果线程2发起修改b,因为缓存行已经失效了,所以「core2 这时会重新从主内存中读取该 Cache line 数据」。读完后,因为它要修改b的值,那么CPU2就通知其他CPU核,当前缓存行(Cache line)又已经失效。
  • 如果同一个Cache line的内容被多个线程读写,就很容易产生相互竞争,频繁回写主内存,会大大降低性能。

14.2 如何解决伪共享问题

既然伪共享是因为相互独立的变量存储到相同的Cache line导致的,一个缓存行大小是64字节。那么,我们就可以使用空间换时间的方法,即数据填充的方式,把独立的变量分散到不同的Cache line~

看个例子:

class Rectangle {
    volatile long a;
    volatile long b;
}
public class Test {
    public static void main(String[] args) throws InterruptedException {
        Rectangle rectangle = new Rectangle();
        long beginTime = System.currentTimeMillis();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                rectangle.a = rectangle.a + 1;
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                rectangle.b = rectangle.b + 1;
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println("执行时间" + (System.currentTimeMillis() - beginTime));
    }

//运行结果:
执行时间2859

一个long类型是8字节,我们在变量a和b之间补上7个long类型变量呢,输出结果是啥呢?如下:

class Rectangle {
    volatile long a;
    long a1,a2,a3,a4,a5,a6,a7;
    volatile long b;
}
//运行结果
执行时间1018

可以发现利用填充数据的方式,让读写的变量分割到不同缓存行,可以很好挺高性能~

15. Fork/Join框架的理解

Fork/Join框架是Java7提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。

Fork/Join框架需要理解两个点,「分而治之」「工作窃取算法」

分而治之

以上Fork/Join框架的定义,就是分而治之思想的体现啦
一文让你彻底了解多线程_第20张图片

工作窃取算法

把大任务拆分成小任务,放到不同队列执行,交由不同的线程分别执行时,有的线程优先把自己负责的任务执行完了,其他线程还在慢慢悠悠处理自己的任务,这时候为了充分提高效率,就需要工作盗窃算法啦~
一文让你彻底了解多线程_第21张图片

工作盗窃算法就是,「某个线程从其他队列中窃取任务进行执行的过程」。一般就是指做得快的线程(盗窃线程)抢慢的线程的任务来做,同时为了减少锁竞争,通常使用双端队列,即快线程和慢线程各在一端。

16. 聊聊ThreadLocal原理?

ThreadLocal是很重要的一部分知识,通过线程隔离的方式保证线程安全,但是因为其解决hash冲突的效率低下,netty又在其基础上推出了fastThreadLocal,本节带大家了解一下,如果有需求想更深入的了解一下,可以读一下下面两篇文章:

  • 《ThreadLocal详解》
  • 《谈谈FastLocal为啥这么快》

ThreadLocal的内存结构图

为了对ThreadLocal有个宏观的认识,我们先来看下ThreadLocal的内存结构图
一文让你彻底了解多线程_第22张图片

从内存结构图,我们可以看到:

  • Thread类中,有个ThreadLocal.ThreadLocalMap的成员变量。
  • ThreadLocalMap内部维护了Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型对象值。

所以怎么回答ThreadLocal的实现原理?

  • Thread线程类有一个类型为ThreadLocal.ThreadLocalMap的实例变量
  • ThreadLocals,即每个线程都有一个属于自己的ThreadLocalMap。
  • ThreadLocalMap内部维护着Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型值。
  • 并发多线程场景下,每个线程Thread,在往ThreadLocal里设置值的时候,都是往自己的ThreadLocalMap里存,读也是以某个ThreadLocal作为引用,在自己的map里找对应的key,从而可以实现了线程隔离。

16.1 TreadLocal为什么会导致内存泄漏呢?

我们从下面三个 方面去探讨一下内存泄漏的问题:

  • 弱引用导致的内存泄漏
  • key是弱引用,GC回收会影响ThreadLocal的正常工作嘛?
  • ThreadLocal内存泄漏的demo

弱引用导致的内存泄漏

何为内存泄漏?

内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

我们先来看看TreadLocal的引用示意图:
一文让你彻底了解多线程_第23张图片

ThreadLocalMap使用ThreadLocal的弱引用作为key,当ThreadLocal变量被手动设置为null,即一个ThreadLocal没有外部强引用来引用它,当系统GC时,ThreadLocal一定会被回收。

这样的话,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话(比如线程池的核心线程),这些key为null的Entry的value就会一直存在一条强引用链:Thread变量 -> Thread对象 -> ThreaLocalMap -> Entry -> value -> Object 永远无法回收,造成内存泄漏。

当ThreadLocal变量被手动设置为null后的引用链图:
一文让你彻底了解多线程_第24张图片

实际上,ThreadLocalMap的设计中已经考虑到这种情况。所以也加上了一些防护措施:即在ThreadLocal的get,set,remove方法,都会清除线程ThreadLocalMap里所有key为null的value。

一文让你彻底了解多线程_第25张图片

其他方法如get,remove等都有此判断,我就不一一截图了。

16.2 key是弱引用,GC回收会影响ThreadLocal的正常工作嘛?

有些小伙伴可能有疑问,ThreadLocal的key既然是弱引用.会不会GC贸然把key回收掉,进而影响ThreadLocal的正常使用?

弱引用:具有弱引用的对象拥有更短暂的生命周期。如果一个对象只有弱引用存在了,则下次GC将会回收掉该对象(不管当前内存空间足够与否)

其实不会的,因为有ThreadLocal变量引用着它,是不会被GC回收的,除非手动把ThreadLocal变量设置为null,我们可以跑个demo来验证一下:

  public static void main(String[] args) {
        Object object = new Object();
        WeakReference<Object> testWeakReference = new WeakReference<>(object);
        System.out.println("GC回收之前,弱引用:"+testWeakReference.get());
        //触发系统垃圾回收
        System.gc();
        System.out.println("GC回收之后,弱引用:"+testWeakReference.get());
        //手动设置为object对象为null
        object=null;
        System.gc();
        System.out.println("对象object设置为null,GC回收之后,弱引用:"+testWeakReference.get());
    }

一文让你彻底了解多线程_第26张图片

16.3 ThreadLocal内存泄漏的demo

给大家来看下一个内存泄漏的例子,其实就是用线程池,一直往里面放对象

public class Test {
    private static ThreadLocal<TianLuoClass> tianLuoThreadLocal =
     new ThreadLocal<>();
    static class TianLuoClass {
        // 100M
        private byte[] bytes = new byte[100 * 1024 * 1024];
    }
    public static void main(String[] args) throws InterruptedException {

        ThreadPoolExecutor threadPoolExecutor = 
        new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES, 
        new LinkedBlockingQueue<>());

        for (int i = 0; i < 10; ++i) {
            threadPoolExecutor.execute(new Runnable() {
                @Override
                public void run() {
                    System.out.println("创建对象:");
                    TianLuoClass tianLuoClass = new TianLuoClass();
                    tianLuoThreadLocal.set(tianLuoClass);
                    //将对象设置为 null,表示此对象不在使用了
                    tianLuoClass = null; 
                    // tianLuoThreadLocal.remove();
                }
            });
            Thread.sleep(1000);
        }
    }
}

//运行结果:
创建对象:
创建对象:
创建对象:
创建对象:
Exception in thread "pool-1-thread-4" java.lang.OutOfMemoryError: Java heap space
 at com.example.dto.ThreadLocalTestDemo$TianLuoClass.<init>(ThreadLocalTestDemo.java:33)
 at com.example.dto.ThreadLocalTestDemo$1.run(ThreadLocalTestDemo.java:21)
 at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
 at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
 at java.lang.Thread.run(Thread.java:748)

运行结果出现了OOM,tianLuoThreadLocal.remove();加上后,则不会OOM。

我们这里没有手动设置tianLuoThreadLocal变量为null,但是还是会内存泄漏。因为我们使用了线程池,线程池有很长的生命周期,因此线程池会一直持有tianLuoClass对象的value值,即使设置tianLuoClass = null;引用还是存在的。

这就好像,你把一个个对象object放到一个list列表里,然后再单独把object设置为null的道理是一样的,列表的对象还是存在的。

如果我们加上threadLocal.remove();,则不会内存泄漏。为什么呢?因为threadLocal.remove();会清除Entry

16.4 为什么ThreadLocalMap 的 key是弱引用,设计理念是?

通过阅读ThreadLocal的源码,我们是可以看到Entry的Key是设计为弱引用的(ThreadLocalMap使用ThreadLocal的弱引用作为Key的)。为什么要设计为弱引用呢?
一文让你彻底了解多线程_第27张图片

我们先来回忆一下四种引用:

  • 强引用:我们平时new了一个对象就是强引用,例如 Object obj = new Object();即使在内存不足的情况下,JVM宁愿抛出OutOfMemory错误也不会回收这种对象。
  • 软引用:如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。
  • 弱引用:具有弱引用的对象拥有更短暂的生命周期。如果一个对象只有弱引用存在了,则下次GC将会回收掉该对象(不管当前内存空间足够与否)。
  • 虚引用:如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动。

下面我们分情况讨论:

  • 如果Key使用强引用:当ThreadLocal的对象被回收了,但是ThreadLocalMap还持有ThreadLocal的强引用的话,如果没有手动删除,ThreadLocal就不会被回收,会出现Entry的内存泄漏问题。
  • 如果Key使用弱引用:当ThreadLocal的对象被回收了,因为
    ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。value则在下一次ThreadLocalMap调用set,get,remove的时候会被清除。

因此可以发现,使用弱引用作为Entry的Key,可以多一层保障:弱引用ThreadLocal不会轻易内存泄漏,对应的value在下一次ThreadLocalMap调用set,get,remove的时候会被清除。

实际上,我们的内存泄漏的根本原因是,不再被使用的Entry,没有从线程的ThreadLocalMap中删除。一般删除不再使用的Entry有这两种方式:

  • 一种就是,使用完ThreadLocal,手动调用remove(),把Entry从ThreadLocalMap中删除
  • 另外一种方式就是:ThreadLocalMap的自动清除机制去清除过期Entry.(ThreadLocalMap的get(),set()时都会触发对过期Entry的清除)

16.5 如何保证父子线程间的共享ThreadLocal数据

我们知道ThreadLocal是线程隔离的,如果我们希望父子线程共享数据,如何做到呢?可以使用InheritableThreadLocal。先来看看demo:

    public static void main(String[] args) {
        ThreadLocal<String> threadLocal = new ThreadLocal<>();
        InheritableThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();

        threadLocal.set("threadLocal:关注一下再走呗");
        inheritableThreadLocal.set("inheritableThreadLocal:关注一下再走呗");

        Thread thread = new Thread(()->{
            System.out.println("ThreadLocal value " + threadLocal.get());
            System.out.println("InheritableThreadLocal value " + inheritableThreadLocal.get());
        });
        thread.start();

    }

一文让你彻底了解多线程_第28张图片

可以发现,在子线程中,是可以获取到父线程的InheritableThreadLocal 类型变量的值,但是不能获取到 ThreadLocal 类型变量的值。

获取不到ThreadLocal 类型的值,我们可以好理解,因为它是线程隔离的嘛。InheritableThreadLocal 是如何做到的呢?原理是什么呢?

在Thread类中,除了成员变量threadLocals之外,还有另一个成员变量:inheritableThreadLocals。它们两类型是一样的:

public class Thread implements Runnable {
   ThreadLocalMap threadLocals = null;
   ThreadLocalMap inheritableThreadLocals = null;
 }

Thread类的init方法中,有一段初始化设置:

 private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize, AccessControlContext acc,
                      boolean inheritThreadLocals) {
      
        ......
        if (inheritThreadLocals && parent.inheritableThreadLocals != null)
            this.inheritableThreadLocals =
                ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
        /* Stash the specified stack size in case the VM cares */
        this.stackSize = stackSize;

        /* Set thread ID */
        tid = nextThreadID();
    }
 static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
        return new ThreadLocalMap(parentMap);
    }

可以发现,当parent的inheritableThreadLocals不为null时,就会将parent的inheritableThreadLocals,赋值给前线程的inheritableThreadLocals。

说白了,就是如果当前线程的inheritableThreadLocals不为null,就从父线程哪里拷贝过来一个过来,类似于另外一个ThreadLocal,但是数据从父线程那里来的。

17.如何保证多线程下 i++ 结果正确?

一文让你彻底了解多线程_第29张图片

public class AtomicIntegerTest {

    private static AtomicInteger atomicInteger = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        testIAdd();
    }

    private static void testIAdd() throws InterruptedException {
        //创建线程池
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        for (int i = 0; i < 1000; i++) {
            executorService.execute(() -> {
                for (int j = 0; j < 2; j++) {
                    //自增并返回当前值
                    int andIncrement = atomicInteger.incrementAndGet();
                    System.out.println("线程:" + Thread.currentThread().getName() + " count=" + andIncrement);
                }
            });
        }
        executorService.shutdown();
        Thread.sleep(100);
        System.out.println("最终结果是 :" + atomicInteger.get());
    }
    
}

一文让你彻底了解多线程_第30张图片

18.如何检测死锁?怎么预防死锁?死锁四个必要条件

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去,是操作系统层面的一个错误。
一文让你彻底了解多线程_第31张图片

死锁的四个必要条件:

  • 互斥:进程对所分配到的资源不允许其他进程进行访问,若其他进程访问该资源,只能等待,直至占有该资源的进程使用完成后释放该资源。
  • 请求和保持条件:进程获得一定的资源之后,又对其他资源发出请求,但是该资源可能被其他进程占有,此时请求阻塞,但又对自己获得的资源保持不放。
  • 不可剥夺条件:是指进程已获得的资源,在未完成使用之前,不可被剥夺,只能在使用完后自己释放。
  • 循环等待:存在一个封闭的进程链,使得每个资源至少占有此链中下一个进程所需要的一个资源。

如何预防死锁?

  • 加锁顺序(线程按顺序办事)
  • 加锁时限 (线程请求所加上权限,超时就放弃,同时释放自己占有的锁)
  • 死锁检测

19.如果线程过多,会怎样?

使用多线程可以提升程序性能。但是如果使用过多的线程,则适得其反。

过多的线程会影响程序的系统。

  • 一方面,线程的启动和销毁,都是需要开销的。
  • 其次,过多的并发线程也会导致共享有限资源的开销增大。过多的线程,还会导致内存泄漏,比如:如果有一个第三方的包是使用new Thread来实现的,使用完没有恰当回收销毁,最终将会引发内存泄漏问题。

因此,我们平时尽量使用线程池来管理线程。同时还需要设置恰当的线程数。

20.聊聊happens-before原则

在Java语言中,有一个先行发生原则(happens-before)。它包括八大规则,如下:

  • 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。
  • 管程锁定规则:一个unLock操作先行发生于后面对同一个锁lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 线程终止规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
  • 传递性:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

21.如何实现两个线程间共享数据

1.可以通过类变量直接将数据放到主存中

2.通过并发的数据结构来存储数据

3.使用volatile变量或者锁

4.调用atomic类(如AtomicInteger)

22.LockSupport作用是?

LockSupport是一个工具类。它的主要作用是挂起唤醒线程。该工具类是创建锁和其他同步类的基础。它的主要方法是:

public static void park(Object blocker); // 暂停指定线程
public static void unpark(Thread thread); // 恢复指定的线程
public static void park(); // 无期限暂停当前线程
//运行结果:
恢复线程调用
线程名字: Thread[Ninesun,5,main]
继续执行

下面很多个章节都是线程池的讲解,我有一篇文章对线程池进行了完整的讲解,大家看完下面的内容如果感觉不过瘾可以移步至此:《线程池很难吗?带你深入浅出线程池》

23.线程池如何调优,如何确认最佳线程数?

最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目

我们通过一个例子具体看一下:

我们的服务器CPU核数为8核,一个任务线程cpu耗时为20ms,线程等待(网络IO、磁盘IO)耗时80ms,那最佳线程数目:( 80 + 20 )/20 * 8 = 40。也就是设置 40个线程数最佳。

24.为什么要用线程池?

使用线程池最大的原因就是可以根据系统的需求和硬件环境灵活的控制线程的数量,且可以对所有线程进行统一的管理和控制,从而提高系统的运行效率,降低系统运行运行压力;
当然了,使用线程池的原因不仅仅只有这些,我们可以从线程池自身的优点上来进一步了解线程池的好处:

  • 降低资源消耗:线程池通常会维护一些线程(数量为 corePoolSize),这些线程被重复使用来执行不同的任务,任务完成后不会销毁。在待处理任务量很大的时候,通过对线程资源的复用,避免了线程的频繁创建与销毁,从而降低了系统资源消耗。
  • 提高响应速度:由于线程池维护了一批 alive 状态的线程,当任务到达时,不需要再创建线程,而是直接由这些线程去执行任务,从而减少了任务的等待时间。
  • 提高线程的可管理性:使用线程池可以对线程进行统一的分配,调优和监控。

25.Java的线程池执行原理

线程池的执行原理如下:
一文让你彻底了解多线程_第32张图片

为了形象描述线程池执行,打个比喻:

  • 核心线程比作公司正式员工
  • 非核心线程比作外包员工
  • 阻塞队列比作需求池
  • 提交任务比作提需求

一文让你彻底了解多线程_第33张图片

26.聊聊线程池的核心参数

我们研究一个类建议从构造函数开始读起,我们先来看看ThreadPoolExecutor的构造函数:

public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
   long keepAliveTime,
   TimeUnit unit,
   BlockingQueue<Runnable> workQueue,
   ThreadFactory threadFactory,
   RejectedExecutionHandler handler)
  • corePoolSize:线程池核心线程数最大值
  • maximumPoolSize:线程池最大线程数大小
  • keepAliveTime:线程池中非核心线程空闲的存活时间大小
  • unit:线程空闲存活时间单位
  • workQueue:存放任务的阻塞队列
  • threadFactory:用于设置创建线程的工厂,可以给创建的线程设置有意义的名字,可方便排查问题。
  • handler:线城池的饱和策略事件,主要有四种类型拒绝策略。

四种拒绝策略:

  • AbortPolicy(抛出一个异常,默认的)
  • DiscardPolicy(直接丢弃任务)
  • DiscardOldestPolicy(丢弃队列里最老的任务,将当前这个任务继续提交给线程池)
  • CallerRunsPolicy(交给线程池调用所在的线程进行处理)

几种工作阻塞队列

  • ArrayBlockingQueue(用数组实现的有界阻塞队列,按FIFO排序量)
  • LinkedBlockingQueue(基于链表结构的阻塞队列,按FIFO排序任务,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列)
  • DelayQueue(一个任务定时周期的延迟执行的队列)
  • PriorityBlockingQueue(具有优先级的无界阻塞队列)
  • SynchronousQueue(一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态)

27.当提交新任务时,异常如何处理?

我们先来看一段代码:

  ExecutorService threadPool = Executors.newFixedThreadPool(5);
  for (int i = 0; i < 5; i++) {
      threadPool.submit(() -> {
          System.out.println("current thread name" + Thread.currentThread().getName());
          Object object = null;
          System.out.print("result## "+object.toString());
      });
  }

显然,这段代码会有异常,我们再来看看执行结果
一文让你彻底了解多线程_第34张图片

虽然没有结果输出,但是没有抛出异常,所以我们无法感知任务出现了异常,所以需要添加try/catch。 如下图:
一文让你彻底了解多线程_第35张图片

所以,线程的异常处理,我们可以直接try…catch捕获。

28.AQS组件,实现原理

AQS是用来构建锁或者其他同步器组件的重量级基础框架及整个JUC体系的基石,通过内置的FIFO队列来完成资源获取线程的排队工作,并通过一个int类型的变量state表示持有锁的状态;

AQS,即AbstractQueuedSynchronizer,抽象的队列同步器,是构建锁或者其他同步组件的基础框架及整个JUC体系的基石,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。可以回答以下这几个关键点哈:

  • state 状态的维护。
  • CLH队列
  • ConditionObject通知
  • 模板方法设计模式
  • 独占与共享模式。
  • 自定义同步器。

AQS全家桶的一些延伸,如:ReentrantLock等。

28.1 state 状态的维护

  • state,int变量,锁的状态,用volatile修饰,保证多线程中的可见性
  • getState()和setState()方法采用final修饰,限制AQS的子类重写它们俩。
  • compareAndSetState()方法采用乐观锁思想的CAS算法操作确保线程安全,保证状态 设置的原子性。

28.2 CLH队列

一文让你彻底了解多线程_第36张图片

CLH 同步队列,全英文Craig, Landin, and Hagersten locks。

是一个FIFO双向队列,其内部通过节点head和tail记录队首和队尾元素,队列元素的类型为Node。AQS依赖它来完成同步状态state的管理,当前线程如果获取同步状态失败时,AQS则会将当前线程已经等待状态等信息构造成一个节点(Node)并将其加入到CLH同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点唤醒(公平锁),使其再次尝试获取同步状态。

28.3 ConditionObject通知

我们都知道,synchronized控制同步的时候,可以配合Object的wait()、notify(),notifyAll() 系列方法可以实现等待/通知模式。

而Lock呢?它提供了条件Condition接口,配合await(),signal(),signalAll() 等方法也可以实现等待/通知机制。ConditionObject实现了Condition接口,给AQS提供条件变量的支持
一文让你彻底了解多线程_第37张图片

ConditionObject队列与CLH队列的爱恨情仇:

  • 调用了await()方法的线程,会被加入到conditionObject等待队列中,并且唤醒CLH队列中head节点的下一个节点。
  • 线程在某个ConditionObject对象上调用了singnal()方法后,等待队列中的firstWaiter会被加入到AQS的CLH队列中,等待被唤醒。
  • 当线程调用unLock()方法释放锁时,CLH队列中的head节点的下一个节点(在本例中是firtWaiter),会被唤醒。

28.4 模板方法设计模式

模板方法模式:在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。

AQS的典型设计模式就是模板方法设计模式啦。AQS全家桶(ReentrantLock,Semaphore)的衍生实现,就体现出这个设计模式。如AQS提供tryAcquire,tryAcquireShared等模板方法,给子类实现自定义的同步器。

28.5 独占与共享模式

  • 独占式: 同一时刻仅有一个线程持有同步状态,如ReentrantLock。又可分为公平锁和非公平锁。
  • 共享模式:多个线程可同时执行,如Semaphore/CountDownLatch等都是共享式的产物。

28.6 自定义同步器

你要实现自定义锁的话,首先需要确定你要实现的是独占锁还是共享锁,定义原子变量state的含义,再定义一个内部类去继承AQS,重写对应的模板方法即可啦

29.Semaphore原理

Semaphore,我们也把它叫做信号量。可以用来控制同时访问特定资源的线程数量,通过协调各个线程,以保证合理的使用资源。

我们可以把它简单的理解成我们停车场入口立着的那个显示屏,每当有一辆车进入停车场显示屏就会显示剩余车位减1,每有一辆车从停车场出去,显示屏上显示的剩余车辆就会加1,当显示屏上的剩余车位为0时,停车场入口的栏杆就不会再打开,车辆就无法进入停车场了,直到有一辆车从停车场出去为止。

29.1 Semaphore使用demo

我们就以停车场的例子,来实现demo。

假设停车场最多可以停20辆车,现在有100辆要进入停车场。

   private  static Semaphore semaphore=new Semaphore(20);

    public static void main(String[] args) {
         
        ExecutorService executorService= Executors.newFixedThreadPool(200);

        //模拟100辆车要来
        for (int i = 0; i < 100; i++) {
            executorService.execute(()->{
                System.out.println("===="+Thread.currentThread().getName()+"准备进入停车场==");
                //车位判断
                if (semaphore.availablePermits() == 0) {
                    System.out.println("车辆不足,请耐心等待");
                }

                try {
                    //获取令牌尝试进入停车场
                    semaphore.acquire();
                    System.out.println("====" + Thread.currentThread().getName() + "成功进入停车场");
                    //模拟车辆在停车场停留的时间
                    Thread.sleep(new Random().nextInt(20000));
                    System.out.println("====" + Thread.currentThread().getName() + "驶出停车场");
                    //释放令牌,腾出停车场车位
                    semaphore.release();
                 } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            });
            //线程池关闭          
            executorService.shutdown();
        }
    }

我们通过以下几个方面来进行分析:

  • Semaphore构造函数
  • 可用令牌数
  • 获取令牌
  • 释放令牌

Semaphore构造函数

Semaphore semaphore=new Semaphore(20);

它会创建一个非公平的锁的同步阻塞队列,并且把初始令牌数量(20)赋值给同步队列的state,这个state就是AQS的。
其源码如下:

//构造函数,创建一个非公平的锁的同步阻塞队列
 public Semaphore(int permits) {
    sync = new NonfairSync(permits);
}
    
NonfairSync(int permits) {
    super(permits);
}

//把令牌数量赋值给同步队列的state
Sync(int permits) {
    setState(permits);
}

可用令牌数

这个availablePermits,获取的就是state值。刚开始为20,所以肯定不会为0嘛。
源码如下:

semaphore.availablePermits();

public int availablePermits() {
  return sync.getPermits();
}

final int getPermits() {
  return getState();
}

获取令牌
接着我们再看下获取令牌的API

semaphore.acquire();

尝试获取令牌,使用了CAS算法。

final int nonfairTryAcquireShared(int acquires) {
            for (;;) {
                int available = getState();
                int remaining = available - acquires;
                if (remaining < 0 ||
                    compareAndSetState(available, remaining))
                    return remaining;
            }
        }

可获取令牌的话,就创建节点,加入阻塞队列;重双向链表的head,tail节点关系,清空无效节点;挂起当前节点线程

释放令牌

 semaphore.release();
 
  /**
     * 释放令牌
     */
public void release() {
    sync.releaseShared(1);
}

  public final boolean releaseShared(int arg) {
         //释放共享锁
        if (tryReleaseShared(arg)) {
            //唤醒所有共享节点线程
            doReleaseShared();
            return true;
        }
        return false;
    }

30.synchronized做了哪些优化?什么是偏向锁?什么是自旋锁?锁租化?

在JDK1.6之前,synchronized的实现直接调用ObjectMonitor的enter和exit,这种锁被称之为重量级锁。

从JDK1.6开始,HotSpot虚拟机开发团队对Java中的锁进行优化,如增加了适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等优化策略,提升了synchronized的性能。

  • 偏向锁:在无竞争的情况下,只是在Mark Word里存储当前线程指针,CAS操作都不做。大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
  • 轻量级锁:在没有多线程竞争时,相对重量级锁,减少操作系统互斥量带来的性能消耗。但是,如果存在锁竞争,除了互斥量本身开销,还额外有CAS操作的开销。
  • 自旋锁:减少不必要的CPU上下文切换。在轻量级锁升级为重量级锁时,就使用了自旋加锁的方式
    • 锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
    • 锁消除:虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。

值得我们注意的是锁可以升级但不能降级

31.什么是上下文切换?

什么是CPU上下文?

CPU 寄存器,是CPU内置的容量小、但速度极快的内存。而程序计数器,则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。它们都是 CPU 在运行任何任务前,必须的依赖环境,因此叫做CPU上下文。

什么是CPU上下文切换?

它是指,先把前一个任务的CPU上下文(也就是CPU寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

一般我们说的上下文切换,就是指内核(操作系统的核心)在CPU上对进程或者线程进行切换。

进程从用户态内核态的转变,需要通过系统调用来完成。系统调用的过程,会发生CPU上下文的切换。

所以大家有时候会听到这种说法,线程的上下文切换。 它指,CPU资源的分配采用了时间片轮转,即给每个线程分配一个时间片,线程在时间片内占用 CPU 执行任务。当线程使用完时间片后,就会处于就绪状态并让出 CPU 让其他线程占用,这就是线程的上下文切换。看个图,可能会更容易理解一点
一文让你彻底了解多线程_第38张图片

32.为什么wait(),notify(),notifyAll()在对象中,而不在Thread类中

锁只是个一个标记,存在对象头里面。

下面从面向对象观察者模式角度来分析。

面向对象的角度:我们可以把wait和notify直接理解为get和set方法。wait和notify方法都是对对象的锁进行操作,那么自然这些方法应该属于对象。举例来说,门对象上有锁属性,开锁和关锁的方法应该属于门对象,而不应该属于人对象。

从观察者模式的角度:对象是被观察者,线程是观察者。被观察者的状态如果发生变化,理应有被观察者去轮询通知观察者,否则的话,观察者怎么知道notify方法应该在哪个时刻调用?n个观察者的notify又如何做到同时调用?

33.线程池中 submit()和 execute()方法有什么区别?

  • execute和submit都属于线程池的方法,execute只能提交Runnable类型的任务,而submit既能提交Runnable类型任务也能提交Callable类型任务。
  • execute会直接抛出任务执行时的异常,submit会吃掉异常,可通过Future的get方法将任务执行时的异常重新抛出。
  • execute所属顶层接口是Executor,submit所属顶层接口是ExecutorService,实现类ThreadPoolExecutor重写了execute方法,抽象类AbstractExecutorService重写了submit方法。

34.AtomicInteger 的原理?

AtomicInteger的底层,是基于CAS实现的。我们可以看下AtomicInteger的添加方法。如下

    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
    通过Unsafe类的实例来进行添加操作
    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));//使用了CAS算法实现

        return var5;
    }

注意:compareAndSwapInt(CAS)是一个native方法,它是基于CAS来操作int类型的变量。并且,其它的原子操作类基本也大同小异。

35. Java中用到的线程调度算法是什么?

我们知道有两种调度模型:分时调度抢占式调度

  • 分时调度模型:让所有的线程轮流获得cpu的使用权,并且平均分配每个线程占用的 CPU 的时间片。
  • 抢占式调度:优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃 CPU。

Java默认的线程调度算法是抢占式。即线程用完CPU之后,操作系统会根据线程优先级、线程饥饿情况等数据算出一个总的优先级并分配下一个时间片给某个线程执行。

36.shutdown() 和 shutdownNow()的区别

  • shutdownNow()能立即停止线程池,正在跑的和正在等待的任务都停下了。这样做立即生效,但是风险也比较大。
  • shutdown()只是关闭了提交通道,用submit()是无效的;而内部的任务该怎么跑还是怎么跑,跑完再彻底停止线程池。

37.说说几种常见的线程池及使用场景?

  • newFixedThreadPool (固定数目线程的线程池)
  • newCachedThreadPool(可缓存线程的线程池)
  • newSingleThreadExecutor(单线程的线程池)
  • newScheduledThreadPool(定时及周期执行的线程池)

newFixedThreadPool

 public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>(),
                                      threadFactory);
    }
  • 核心线程数和最大线程数大小一样
  • 没有所谓的非空闲时间,即keepAliveTime为0
  • 阻塞队列为无界队列LinkedBlockingQueue

使用场景

FixedThreadPool 适用于处理CPU密集型的任务,确保CPU在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。

newCachedThreadPool

 public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>(),
                                      threadFactory);
    }
  • 核心线程数为0
  • 最大线程数为Integer.MAX_VALUE
  • 阻塞队列是SynchronousQueue
  • 非核心线程空闲存活时间为60秒

使用场景

当提交任务的速度大于处理任务的速度时,每次提交一个任务,就必然会创建一个线程。极端情况下会创建过多的线程,耗尽 CPU 和内存资源。由于空闲 60 秒的线程会被终止,长时间保持空闲的 CachedThreadPool 不会占用任何资源。

newSingleThreadExecutor 单线程的线程池

 public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>(),
                                    threadFactory));
    }
  • 核心线程数为1
  • 最大线程数也为1
  • 阻塞队列是LinkedBlockingQueue
  • keepAliveTime为0

使用场景

适用于串行执行任务的场景,一个任务一个任务地执行。

newScheduledThreadPool

public ScheduledThreadPoolExecutor(int corePoolSize) {
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue());
    }
  • 最大线程数为Integer.MAX_VALUE
  • 阻塞队列是DelayedWorkQueue
  • keepAliveTime为0
  • scheduleAtFixedRate() :按某种速率周期执行
  • scheduleWithFixedDelay():在某个延迟后执行

使用场景

周期性执行任务的场景,需要限制线程数量的场景

38.什么是FutureTask

FutureTask是一种可以取消的异步的计算任务。它的计算是通过Callable实现的,可以把它理解为是可以返回结果的Runnable。

使用FutureTask的优点:

  • 可以获取线程执行后的返回结果;
  • 提供了超时控制功能。
  • 它实现了Runnable接口和Future接口,底层基于生产者消费者模式实现。

FutureTask用于在异步操作场景中,FutureTask作为生产者(执行FutureTask的线程)和消费者(获取FutureTask结果的线程)的桥梁,如果生产者先生产出了数据,那么消费者get时能会直接拿到结果;如果生产者还未产生数据,那么get时会一直阻塞或者超时阻塞,一直到生产者产生数据唤醒阻塞的消费者为止。

39. java中interrupt(),interrupted()和isInterrupted()的区别

  • interrupt 它是真正触发中断的方法。
  • interrupted是Thread中的一个类方法,它也调用了isInterrupted(true)方法,不过它传递的参数是true,表示将会清除中断标志位。
  • isInterrupted是Thread类中的一个实例方法,可以判断实例线程是否被中断。
    public void interrupt() {
        if (this != Thread.currentThread())
            checkAccess();

        synchronized (blockerLock) {
            Interruptible b = blocker;
            if (b != null) {
                interrupt0();           // Just to set the interrupt flag
                b.interrupt(this);
                return;
            }
        }
        interrupt0();
    }
    
   public static boolean interrupted() {
        return currentThread().isInterrupted(true);
    }

    public boolean isInterrupted() {
        return isInterrupted(false);
    }

40.有三个线程T1,T2,T3,怎么确保它们按顺序执行

可以使用join方法解决这个问题。

比如在线程A中,调用线程B的join方法表示的意思就是:A等待B线程执行完毕后(释放CPU执行权),在继续执行。

public class ThreadTest {

    public static void main(String[] args) {

        Thread spring = new Thread(new SeasonThreadTask("春天"));
        Thread summer = new Thread(new SeasonThreadTask("夏天"));
        Thread autumn = new Thread(new SeasonThreadTask("秋天"));

        try
        {
            //春天线程先启动
            spring.start();
            //主线程等待线程spring执行完,再往下执行
            spring.join();
            //夏天线程再启动
            summer.start();
            //主线程等待线程summer执行完,再往下执行
            summer.join();
            //秋天线程最后启动
            autumn.start();
            //主线程等待线程autumn执行完,再往下执行
            autumn.join();
        } catch (InterruptedException e)
        {
            e.printStackTrace();
        }
    }
}

class SeasonThreadTask implements Runnable{

    private String name;

    public SeasonThreadTask(String name){
        this.name = name;
    }

    @Override
    public void run() {
        for (int i = 1; i <4; i++) {
            System.out.println(this.name + "来了: " + i + "次");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

一文让你彻底了解多线程_第39张图片

41.有哪些阻塞队列

  • ArrayBlockingQueue 一个由数组构成的有界阻塞队列
  • LinkedBlockingQueue 一个由链表构成的有界阻塞队列
  • PriorityBlockingQueue 一个支持优先级排序的无界阻塞队列
  • DelayQueue 一个使用优先队列实现的无界阻塞队列。
  • SynchroniouQueue 一个不储存元素的阻塞队列
  • LinkedTransferQueue 一个由链表结构组成的无界阻塞队列
  • LinkedBlockingDeque 一个由链表结构组成的双向阻塞队列

42.Java中ConcurrentHashMap的并发度是什么?

并发度就是segment的个数,通常是2的N次方。默认是16

43. Java线程有哪些常用的调度方法?

一文让你彻底了解多线程_第40张图片

线程休眠

Thread.sleep(long)方法,使线程转到超时等待阻塞(TIMED_WAITING) 状态。long参数设定睡眠的时间,以毫秒为单位。当睡眠结束后,线程自动转为就绪(Runnable)状态。

线程中断

interrupt()表示中断线程。需要注意的是,InterruptedException是线程自己从内部抛出的,并不是interrupt()方法抛出的。对某一线程调用interrupt()时,如果该线程正在执行普通的代码,那么该线程根本就不会抛出InterruptedException。但是,一旦该线程进入到wait()/sleep()/join()后,就会立刻抛出InterruptedException。可以用isInterrupted()来获取状态。

线程等待
Object类中的wait()方法,会导致当前的线程等待,直到其他线程调用此对象的notify()方法或notifyAll()唤醒方法。

线程让步
Thread.yield()方法,暂停当前正在执行的线程对象,把执行机会让给相同或者更高优先级的线程。

线程通知
Object的notify()方法,唤醒在此对象监视器上等待的单个线程。如果所有线程都在此对象上等待,则会选择唤醒其中一个线程。选择是任意性的,并在对实现做出决定时发生。

notifyAll(),则是唤醒在此对象监视器上等待的所有线程。

44.ReentrantLock的加锁原理

ReentrantLock,是可重入锁,是JDK5中添加在并发包下的一个高性能的工具。它支持同一个线程未释放锁的情况下重复获取锁。

44.1 ReentrantLock使用的模板

我们先来看下是ReentrantLock使用的模板:

   //实例化对象
    ReentrantLock lock = new ReentrantLock();
    //获取锁操作
    lock.lock();
    try {
        // 执行业务代码逻辑
    } catch (Exception ex) {
        //异常处理
    } finally {
        // 解锁操作
        lock.unlock();
    }

44.2 什么是非公平锁,什么是公平锁?

ReentrantLock无参构造函数,默认创建的是非公平锁,如下:

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

而通过fair参数指定使用公平锁(FairSync)还是非公平锁(NonfairSync)

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

什么是公平锁?

公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

优点:所有的线程都能得到资源,不会饿死在队列中。

缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。

什么是非公平锁?

非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。

缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。

44.3 lock()加锁流程

一文让你彻底了解多线程_第41张图片

如果被问到这道题,可以结合AQS + 公平锁/非公平锁 + CAS去讲ReentrantLock的原理

45.线程间的通讯方式

一文让你彻底了解多线程_第42张图片

45.1 volatile和synchronized关键字

volatile关键字用来修饰共享变量,保证了共享变量的可见性,任何线程需要读取时都要到内存中读取(确保获得最新值)。

synchronized关键字确保只能同时有一个线程访问方法或者变量,保证了线程访问的可见性和排他性。

45.2 等待/通知机制

等待/通知机制,是指一个线程A调用了对象的wait()方法进入等待状态,而另一个线程B 调用了对象O的notify()或者notifyAll()方法,线程A收到通知后从对象O的wait()方法返回,进而 执行后续操作。

45.3 管道输入/输出流

管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于,它主要 用于线程之间的数据传输,而传输的媒介为内存。
管道输入/输出流主要包括了如下4种具体实现:PipedOutputStream、PipedInputStream、 PipedReader和PipedWriter,前两种面向字节,而后两种面向字符。

45.4 join()方法

如果一个线程A执行了thread.join()语句,其含义是:当前线程A等待thread线程终止之后才 从thread.join()返回。线程Thread除了提供join()方法之外,还提供了join(long millis)和join(long millis,int nanos)两个具备超时特性的方法。这两个超时方法表示,如果线程thread在给定的超时 时间里没有终止,那么将会从该超时方法中返回。

46.写出3条你遵循的多线程最佳实践

  • 多用同步类,少用wait,notify
  • 少用锁,应当缩小同步范围
  • 给线程一个自己的名字
  • 多用并发集合少用同步集合

47.为什么阿里发布的 Java开发手册中强制线程池不允许使用 Executors 去创建?

这是因为,JDK开发者提供了线程池的实现类都是有坑的,如newFixedThreadPool和newCachedThreadPool都有内存泄漏的坑。

48.细数线程池的10个坑

日常开发中,为了更好管理线程资源,减少创建线程和销毁线程的资源损耗,我们会使用线程池来执行一些异步任务。但是线程池使用不当,就可能会引发生产事故。今天田螺哥跟大家聊聊线程池的10个坑。大家看完肯定会有帮助的~

  • 线程池默认使用无界队列,任务过多导致OOM
  • 线程创建过多,导致OOM
  • 共享线程池,次要逻辑拖垮主要逻辑
  • 线程池拒绝策略的坑
  • Spring内部线程池的坑
  • 使用线程池时,没有自定义命名
  • 线程池参数设置不合理
  • 线程池异常处理的坑
  • 使用完线程池忘记关闭
  • ThreadLocal与线程池搭配,线程复用,导致信息错乱。

PS:OOM表示内存泄漏 out of Memory

48.1 线程池默认使用无界队列,任务过多导致OOM

JDK开发者提供了线程池的实现类,我们基于Executors组件,就可以快速创建一个线程池。日常工作中,一些小伙伴为了开发效率,反手就用Executors新建个线程池。写出类似以下的代码:

public class NewFixedTest {

    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < Integer.MAX_VALUE; i++) {
            executor.execute(() -> {
                try {
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    //do nothing
                }
            });
        }
    }
}

使用newFixedThreadPool创建的线程池,是会有坑的,它默认是无界的阻塞队列,如果任务过多,会导致OOM问题。运行一下以上代码,出现了OOM。

这是因为newFixedThreadPool使用了无界的阻塞队列的LinkedBlockingQueue,如果线程获取一个任务后,任务的执行时间比较长(比如,上面demo代码设置了10秒),会导致队列的任务越积越多,导致机器内存使用不停飙升, 最终出现OOM。

48.2 线程池创建线程过多,导致OOM

有些小伙伴说,既然Executors组件创建出的线程池newFixedThreadPool,使用的是无界队列,可能会导致OOM。那么,Executors组件还可以创建别的线程池,如newCachedThreadPool,我们用它也不行嘛?

我们可以看下newCachedThreadPool的构造函数:

 public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }

它的最大线程数是Integer.MAX_VALUE。大家应该意识到使用它,可能会引发什么问题了吧。没错,如果在以前公司,遇到这么一个OOM问题:一个第三方提供的包,是直接使用new Thread实现多线程的。在某个夜深人静的夜晚,我们的监控系统报警了。。。这个相关的业务请求瞬间特别多,监控系统告警OOM了。创建了大量的线程也有可能引发OOM!

在以前公司,遇到这么一个OOM问题:一个第三方提供的包,是直接使用new Thread实现多线程的。在某个夜深人静的夜晚,我们的监控系统报警了。。。这个相关的业务请求瞬间特别多,监控系统告警OOM了。

所以我们使用线程池的时候,还要当心线程创建过多,导致OOM问题。大家尽量不要使用newCachedThreadPool,并且如果自定义线程池时,要注意一下最大线程数。

48.3 共享线程池,次要逻辑拖垮主要逻辑

要避免所有的业务逻辑共享一个线程池。比如你用线程池A来做登录异步通知,又用线程池A来做对账。如下图:
一文让你彻底了解多线程_第43张图片

如果对账任务checkBillService响应时间过慢,会占据大量的线程池资源,可能直接导致没有足够的线程资源去执行loginNotifyService的任务,最后影响登录。

就这样,因为一个次要服务,影响到重要的登录接口,显然这是绝对不允许的。因此,我们不能将所有的业务一锅炖,都共享一个线程池,因为这样做,风险太高了,犹如所有鸡蛋放到一个篮子里。应当做线程池隔离
一文让你彻底了解多线程_第44张图片

48.4 线程池拒绝策略的坑,使用不当导致阻塞

我们知道线程池主要有四种拒绝策略,如下:

  • AbortPolicy: 丢弃任务并抛出RejectedExecutionException异常。(默认拒绝策略)
  • DiscardPolicy:丢弃任务,但是不抛出异常。
  • DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务。
  • CallerRunsPolicy:由调用方线程处理该任务。

如果线程池拒绝策略设置不合理,就容易有坑。我们把拒绝策略设置为DiscardPolicy或DiscardOldestPolicy并且在被拒绝的任务,Future对象调用get()方法,那么调用线程会一直被阻塞。

我们通过下面一个例子来研究一下:

public class DiscardThreadPoolTest {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 一个核心线程,队列最大为1,最大线程数也是1.拒绝策略是DiscardPolicy
        ThreadPoolExecutor executorService = new ThreadPoolExecutor(1, 1, 1L, TimeUnit.MINUTES,
                new ArrayBlockingQueue<>(1), new ThreadPoolExecutor.DiscardPolicy());

        Future f1 = executorService.submit(()-> {
            System.out.println("提交任务1");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Future f2 = executorService.submit(()->{
            System.out.println("提交任务2");
        });

        Future f3 = executorService.submit(()->{
            System.out.println("提交任务3");
        });

        System.out.println("任务1完成 " + f1.get());// 等待任务1执行完毕
        System.out.println("任务2完成" + f2.get());// 等待任务2执行完毕
        System.out.println("任务3完成" + f3.get());// 等待任务3执行完毕

        executorService.shutdown();// 关闭线程池,阻塞直到所有任务执行完毕

    }
}

一文让你彻底了解多线程_第45张图片

从上图可以看出来,运行结果:一直在运行中。。。

这是因为DiscardPolicy拒绝策略,是什么都没做,源码如下:

public static class DiscardPolicy implements RejectedExecutionHandler {
    /**
      * Creates a {@code DiscardPolicy}.
      */
    public DiscardPolicy() { }

    /**
      * Does nothing, which has the effect of discarding task r.
      */
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    }
}

我们再来看看线程池 submit 的方法:

public Future<?> submit(Runnable task) {
    if (task == null) throw new NullPointerException();
    //把Runnable任务包装为Future对象
    RunnableFuture<Void> ftask = newTaskFor(task, null);
    //执行任务
    execute(ftask);
    //返回Future对象
    return ftask;
}
    
public FutureTask(Runnable runnable, V result) {
  this.callable = Executors.callable(runnable, result);
  this.state = NEW;  //Future的初始化状态是New
}

我们再来看看Future的get() 方法

  //状态大于COMPLETING,才会返回,要不然都会阻塞等待
  public V get() throws InterruptedException, ExecutionException {
        int s = state;
        if (s <= COMPLETING)
            s = awaitDone(false, 0L);
        return report(s);
    }
    
    FutureTask的状态枚举
    private static final int NEW          = 0;
    private static final int COMPLETING   = 1;
    private static final int NORMAL       = 2;
    private static final int EXCEPTIONAL  = 3;
    private static final int CANCELLED    = 4;
    private static final int INTERRUPTING = 5;
    private static final int INTERRUPTED  = 6;

阻塞的真相水落石出啦,FutureTask的状态大于COMPLETING才会返回,要不然都会一直阻塞等待。又因为拒绝策略啥没做,没有修改FutureTask的状态,因此FutureTask的状态一直是NEW,所以它不会返回,会一直等待。

这个问题,可以使用别的拒绝策略,比如CallerRunsPolicy,它让主线程去执行拒绝的任务,会更新FutureTask状态。如果确实想用DiscardPolicy,则需要重写DiscardPolicy的拒绝策略。

温馨提示,日常开发中,使用 Future.get() 时,尽量使用带超时时间的,因为它是阻塞的。

future.get(1, TimeUnit.SECONDS);

难道使用别的拒绝策略,就万无一失了嘛?不是的,如果使用CallerRunsPolicy拒绝策略,它表示拒绝的任务给调用方线程用,如果这是主线程,那会不会可能也导致主线程阻塞呢?总结起来,大家日常开发的时候,多一份心眼吧,多一点思考吧。

48.5 Spring内部线程池的坑

工作中,个别开发者,为了快速开发,喜欢直接用spring的@Async,来执行异步任务。

@Async
public void testAsync() throws InterruptedException {
    System.out.println("处理异步任务");
    TimeUnit.SECONDS.sleep(new Random().nextInt(100));
}

Spring内部线程池,其实是SimpleAsyncTaskExecutor,这玩意有点坑,它不会复用线程的,它的设计初衷就是执行大量的短时间的任务。

也就是说来了一个请求,就会新建一个线程!大家使用spring的@Async时,要避开这个坑,自己再定义一个线程池。正例如下:

@Bean(name = "threadPoolTaskExecutor")
public Executor threadPoolTaskExecutor() {
    ThreadPoolTaskExecutor executor=new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);
    executor.setMaxPoolSize(10);
    executor.setThreadNamePrefix("tianluo-%d");
    // 其他参数设置
    return new ThreadPoolTaskExecutor();
}

48.6 使用线程池时,没有自定义命名

使用线程池时,如果没有给线程池一个有意义的名称,将不好排查回溯问题。这不算一个坑吧,只能说给以后排查埋坑。我还是单独把它放出来算一个点,因为个人觉得这个还是比较重要的。反例如下:

public class ThreadTest {

    public static void main(String[] args) throws Exception {
        ThreadPoolExecutor executorOne = new ThreadPoolExecutor(5, 5, 1, 
                TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(20));
        executorOne.execute(()->{
            System.out.println("关注公众号:捡田螺的小男孩");
            throw new NullPointerException();
        });
    }
}

运行结果如下:

Exception in thread "pool-1-thread-1" java.lang.NullPointerException
 at com.example.dto.ThreadTest.lambda$main$0(ThreadTest.java:17)
 at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
 at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
 at java.lang.Thread.run(Thread.java:748)

可以发现,默认打印的线程池名字是pool-1-thread-1,如果排查问题起来,并不友好。因此建议大家给自己线程池自定义个容易识别的名字。其实用CustomizableThreadFactory即可,正例如下:

public class ThreadTest {

    public static void main(String[] args) throws Exception {
        ThreadPoolExecutor executorOne = new ThreadPoolExecutor(5, 5, 1,
                TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(20),
                new CustomizableThreadFactory("NineSun-Thread-pool"));
        executorOne.execute(()->{
            throw new NullPointerException();
        });
    }
}

48.7 线程池参数设置不合理

线程池最容易出坑的地方,就是线程参数设置不合理。比如核心线程设置多少合理,最大线程池设置多少合理等等。当然,这块不是乱设置的,需要结合具体业务。

比如线程池如何调优,如何确认最佳线程数?

最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目

48.8 线程池异常处理的坑

public class ThreadTest {

    public static void main(String[] args) throws Exception {
        ThreadPoolExecutor executorOne = new ThreadPoolExecutor(5, 5, 1,
                TimeUnit.MINUTES, new ArrayBlockingQueue<Runnable>(20),new CustomizableThreadFactory("NineSun-Thread-pool"));
        for (int i = 0; i < 5; i++) {
            executorOne.submit(()->{
                System.out.println("current thread name" + Thread.currentThread().getName());
                Object object = null;
                System.out.print("result## " + object.toString());
            });
        }

    }
}

按道理,运行这块代码应该抛空指针异常才是的,对吧。但是,运行结果却是这样的;

current thread nameTianluo-Thread-pool1
current thread nameTianluo-Thread-pool2
current thread nameTianluo-Thread-pool3
current thread nameTianluo-Thread-pool4
current thread nameTianluo-Thread-pool5

这是因为使用submit提交任务,不会把异常直接这样抛出来。大家有兴趣的话,可以去看看源码。可以改为execute方法执行,当然最好就是try…catch捕获,如下:

public class ThreadTest {

    public static void main(String[] args) throws Exception {
        ThreadPoolExecutor executorOne = new ThreadPoolExecutor(5, 5, 1,
                TimeUnit.MINUTES, new ArrayBlockingQueue(20),new CustomizableThreadFactory("NineSun-Thread-pool"));
        for (int i = 0; i < 5; i++) {
            executorOne.submit(()->{
                System.out.println("current thread name" + Thread.currentThread().getName());
                try {
                    Object object = null;
                    System.out.print("result## " + object.toString());
                }catch (Exception e){
                    System.out.println("异常了"+e);
                }
            });
        }

    }
}

其实,我们还可以为工作者线程设置UncaughtExceptionHandler,在uncaughtException方法中处理异常。大家知道这个坑就好啦。

48.9 线程池使用完毕后,忘记关闭

如果线程池使用完,忘记关闭的话,有可能会导致内存泄露问题。所以,大家使用完线程池后,记得关闭一下。同时,线程池最好也设计成单例模式,给它一个好的命名,以方便排查问题。

public class ThreadTest {

    public static void main(String[] args) throws Exception {

        ThreadPoolExecutor executorOne = new ThreadPoolExecutor(5, 5, 1,
                TimeUnit.MINUTES,
                 new ArrayBlockingQueue<Runnable>(20),
                  new CustomizableThreadFactory("NineSun-Thread-pool"));
        executorOne.execute(() -> {
            System.out.println("NineSun");
        });

        //关闭线程池
        executorOne.shutdown();
    }
}

48.10 ThreadLocal与线程池搭配,线程复用,导致信息错乱

使用ThreadLocal缓存信息,如果配合线程池一起,有可能出现信息错乱的情况。先看下一下例子:

private static final ThreadLocal<Integer> currentUser = ThreadLocal.withInitial(() -> null);

@GetMapping("wrong")
public Map wrong(@RequestParam("userId") Integer userId) {
    //设置用户信息之前先查询一次ThreadLocal中的用户信息
    String before  = Thread.currentThread().getName() + ":" + currentUser.get();
    //设置用户信息到ThreadLocal
    currentUser.set(userId);
    //设置用户信息之后再查询一次ThreadLocal中的用户信息
    String after  = Thread.currentThread().getName() + ":" + currentUser.get();
    //汇总输出两次查询结果
    Map result = new HashMap();
    result.put("before", before);
    result.put("after", after);
    return result;
}

按理说,每次获取的before应该都是null,但是呢,程序运行在 Tomcat 中,执行程序的线程是Tomcat的工作线程,而Tomcat的工作线程是基于线程池的。

线程池会重用固定的几个线程,一旦线程重用,那么很可能首次从 ThreadLocal 获取的值是之前其他用户的请求遗留的值。这时,ThreadLocal 中的用户信息就是其他用户的信息。

把tomcat的工作线程设置为1

server.tomcat.max-threads=1

用户1,请求过来,会有以下结果,符合预期:
一文让你彻底了解多线程_第46张图片

用户2请求过来,会有以下结果,「不符合预期」:
一文让你彻底了解多线程_第47张图片

因此,使用类似 ThreadLocal 工具来存放一些数据时,需要特别注意在代码运行完后,显式地去清空设置的数据,正例如下:

@GetMapping("right")
public Map right(@RequestParam("userId") Integer userId) {
    String before  = Thread.currentThread().getName() + ":" + currentUser.get();
    currentUser.set(userId);
    try {
        String after = Thread.currentThread().getName() + ":" + currentUser.get();
        Map result = new HashMap();
        result.put("before", before);
        result.put("after", after);
        return result;
    } finally {
        //在finally代码块中删除ThreadLocal中的数据,确保数据不串
        currentUser.remove();
    }
}

49.Thread和Runable的区别

首先Thread是一个类,而Runable是一个接口,在Java语言里面它的一个继承特性,接口可以支持多继承,只能实现单一继承,所以在已经存在继承关系的类里面要实现线程的话,我们只能去实现runable接口

其次,Runable表示一个线程的顶级接口,而 Thread类其实是实现了Runable接口
一文让你彻底了解多线程_第48张图片
我们在使用Thread类或者实现Runable接口的时候,都需要去实现run方法

第三个,站在面向对象这样一个思维来说的话,Runable呢相对是一个任务,而Thread才是真正的一个处理线程,所以我们只需要去用Runable去定义一个具体的任务,然后交给Thread类去处理就好了,这样就达到了一个松耦合的设计目的

第四个,接口表示的是一种规范或者标准,而实现类表示对这个规范或标准的实现,所以站在线程的角度,Thread才是真正意义上的线程实现,而Runable呢,表示线程要执行的一个任务,所以我们可以发现在线程池里面要去提交一个任务的时候,它传递的类型是一个Runable。

总的来说,Thread只是实现了Runable这样一个接口,并且做了线程实现的一个扩展。

50.CompletableFuture默认线程池踩坑,请务必自定义线程池

CompletableFuture是否使用默认线程池的依据,和机器的CPU核心数有关。

当CPU核心数大于1时,才会使用默认的线程池,否则将会为每个CompletableFuture的任务创建一个新线程去执行。
即,CompletableFuture的默认线程池,只有在双核以上的机器内才会使用。

在双核及以下的机器中,会为每个任务创建一个新线程,等于没有使用线程池,且有资源耗尽的风险。

因此建议,在使用CompletableFuture时,务必要自定义线程池。

因为即便是用到了默认线程池,池内的核心线程数,也为机器核心数。也就意味着假设你是4核机器,那最多也只有3个核心线程,对于CPU密集型的任务来说倒还好,但是我们平常写业务代码,更多的是IO密集型任务,对于IO密集型的任务来说,这其实远远不够用的,会导致大量的IO任务在等待,导致吞吐率大幅度下降,即默认线程池比较适用于CPU密集型任务。

51.线程池的生命周期

线程池的运行状态,并不是用户显示设置的,而是伴随着线程池的运行,由内部来维护。

线程池内部使用一个变量维护两个值,将运行状态(runState)和线程数量(workerCount)放在同一个32位的int类型的变量中,前三位代表状态,后29位代表线程的数量;

此时可能就会有人问为什么用同一个变量保存两个值?

可以避免做相关决策时,出现不一致的情况,不必维护两者的一致,而占用锁资源;源码中经常出现判断线程池的运行状态和线程数量的情况;

同时线程池内部设计中使用原子类(AtomicInteger)和大量使用位运算,来避免使用锁同时极大的提高效率。

ThreadPoolExecutor的运行状态有5种:
一文让你彻底了解多线程_第49张图片
一文让你彻底了解多线程_第50张图片

52 sleep与wait有什么区别

sleep()是使线程暂停执行一段时间的方法。wait()也是一种使线程暂停执行的方法,例如,当线程交互时,如果线程对一个同步线程x发出一个wait()调用请求,那么该线程会暂停执行,被调对象进入等待状态,直到被唤醒或等待时间超时。

具体而言,sleep与wait的区别主要表现在以下几个方面:

52.1 原理不同

sleep是Thread类的静态方法,是线程用来控制自身流程的,它会使此线程暂停执行指定时间,而把执行机会会让给其他线程,等到计时时间到时,此线程会自动苏醒。例如,当线程执行报时功能时,每一秒钟打印出一个时间,那么此时就需要在打印方法前面加上一个sleep方法,以便让自己每隔一秒执行一次,该过程如同闹钟一样。

而wait是Object类的方法,用于线程间的通信,这个方法会使当前拥有对象锁的进程等待,直到其他线程调用notify方法(或notifyAll方法)时才醒来。一个开发人员也可以给它指定一个时间,自动醒来。与wait配套的方法有notify和notifyAll。

52.2 对锁的处理机制不同

由于sleep方法的主要作用是让线程休眠指定的一段时间,在时间到时自动恢复,不涉及线程间的通信,因此,调用sleep方法并不会释放锁

而wait方法则不同,当调用wait方法后,线程会释放掉它所占用的锁,从而使线程所在对象的其他synchronized数据可被其他线程使用。举个简单例子,在小明拿遥控器期间,他可以用自己的sleep方法每隔十分钟掉一次电视台,而在他调台休息的十分钟期间,遥控器还在他的手上。

52.3使用区域不同

由于wait方法的特殊意义,所以,它必须放在同步控制方法或者同步语句块使用,而sleep则可以放在任何地方使用。

sleep方法必须捕获异常,而wait,notify以及notifyall不需要捕捉异常。在sleep的过程中,有可能被其他对象调用它的interrupt(),产生InterruptedException异常。

由于sleep不会释放“锁标志”,容易导致死锁问题的发生,所以,一般情况下,不推荐使用sleep方法,而推荐使用wait方法。

你可能感兴趣的:(面试,一文玩转offer,多线程,线程,volatile,synchronized,cas,Fork/join)