从零开始的java面试题(【2021】3.线程、并发相关)

文章目录

  • 0.先了解下操作系统底层的一些知识(懂的跳过)
    • ① 冯诺依曼计算机模型(计算机五大核心组成部分):
    • ② CPU 局部性原理:
    • ③ CPU 运行安全等级:
    • ④ 内存管理-用户态及内核态说明:
    • ⑤ CPU调度的基本单位线程:
  • 1.线程有几种状态?(生命周期)
  • 2.sleep()、wait()、join()、yield()的区别
  • 3.对线程安全的理解
  • 4.对守护线程的理解
  • 5.ThreadLocal的原理和使用场景
  • 6.强引用,软引用,弱引用
  • 7.ThreadLocal内存泄露原因,如何避免?
  • 8.并发的三大特性
  • 9.为什么用线程池?解释下线程池参数?
  • 10.线程池中阻塞队列的作用?为什么是先添加列队而不是先创建最大线程?
  • 11.线程池中线程复用原理

前言

本专栏系列文章仅用于个人学习总结,从零开始逐步到常见服务框架。觉得基础的大佬可以提前离开。欢迎各位大佬评论指教,如有不当之处请及时联系调整 ~

本专栏工作之余抽空更新面试题…

上一篇文章地址:2.java基础进阶篇

 

线程、并发相关


0.先了解下操作系统底层的一些知识(懂的跳过)

  • ① 冯诺依曼计算机模型(计算机五大核心组成部分):

  • 说明:计算机在运行时,先从内存中取出第一条指令,通过控制器的译码,按指令的要求,从存储器中取出数据进行指定的运算和逻辑操作等加工,然后再按地址把结果送到内存中去。接下来,再取出第二条指令,在控制器的指挥下完成规定操作。依此进行下去。直至遇到停止指令。(自动地完成指令规定的操作是计算机最基本的工作模型)
    从零开始的java面试题(【2021】3.线程、并发相关)_第1张图片

    • 控制器(Control): 是整个计算机的中枢神经,其功能是对程序规定的控制信息进行解释,根据其要求进行控制,调度程序、数据、地址,协调计算机各部分工作及内存与外设的访问等。
    • 运算器(Datapath): 运算器的功能是对数据进行各种算术运算和逻辑运算,即对数据进行加工处理。
    • 存储器(Memory): 存储器的功能是存储程序、数据和各种信号、命令等信息,并在需要时提供这些信息。
    • 输入(Input system): 输入设备是计算机的重要组成部分,输入设备与输出设备合称为外部设备,简称外设,输入设备的作用是将程序、原始数据、文字、字符、控制命令或现场采集的数据等信息输入到计算机。常见的输入设备有键盘、鼠标器、光电输入机、磁带机、磁盘机、光盘机等。
    • 输出(Output system): 输出设备与输入设备同样是计算机的重要组成部分,它把外算机的中间结果或最后结果、机内的各种数据符号及文字或各种控制信号等信息输出出来。微机常用的输出设备有显示终端CRT、打印机、激光印字机、绘图仪及磁带、光盘机等。
       
  • ② CPU 局部性原理:

  • 说明:在CPU访问存储设备时,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就被称为局部性原理。

    • 时间局部性(Temporal Locality): 如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。
      例: 某个缓存值被cpu读取到后,在一小段时间内不会清除它,因为可能仍然会用到。(比如循环、递归、方法的反复调用等)
    • 空间局部性(Spatial Locality): 如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。
      例: 内存中 x=0被读取, 其附近的x=0.1也有可能被引用。(比如顺序执行的代码、连续创建的两个对象、数组等)

举个空间局部性原则例子:(第一个案例速度更快)
第一个案例是 longs[0][0] + longs[0][1] + longs[0][2] …longs[0][1024] 一直相加;
第二个案例是 longs[0][0] + longs[1][0] + longs[2][0] …longs[1024][0] 一直相加;
第二个案例一维数组都变了,由空间局部性原则可知, 第二个案例不符合空间局部性原则, 无法读取到附近引用的,效率低。

  • ③ CPU 运行安全等级:

    • ring0(一般指 内核态 , 最高权限!)
    • ring1
    • ring2
    • ring3(一般指 用户态, 平时cpu都是运行在这个状态)

Linux与Windows只用到了2个级别: ring0、ring3。

操作系统内部内部程序指令通常运行在ring0级别,操作系统以外的第三方程序运行在ring3级别。
 
第三方程序如果要调用操作系统内部函数功能,由于运行安全级别不够,必须切换CPU运行状态,从ring3切换到ring0,然后执行系统函数。

JVM创建线程CPU的工作过程:
step1:CPU从ring3切换ring0创建线程
step2:创建完毕,CPU从ring0切换回ring3
step3:线程执行JVM程序
step4:线程执行完毕,销毁还得切回ring0

说到这里相信同学们明白为什么JVM创建线程,线程阻塞唤醒是重型操作了,因为CPU要切换运行状态(用户态、内核态的上下文切换)。

  • ④ 内存管理-用户态及内核态说明:

  • 说明:操作系统有用户空间与内核空间两个概念,目的也是为了做到程序运行安全隔离与稳定,以32位操作系统4G大小的内存空间为例 , 内存空间会划分出用户态内核态

用户空间: 指的就是用户可以操作和访问的空间,这个空间通常存放我们用户自己写的数据等(例 java 的 JVM)。
内核空间: 是系统内核来操作的一块空间,这块空间里面存放系统内核的函数、接口等。

  • ⑤ CPU调度的基本单位线程:

  • 说明:线程的创建,销毁,调度,都用 os去做, 就是KLT,反之为 ULT
    • 内核线程模型(KLT):(java为KLT模型)
      从零开始的java面试题(【2021】3.线程、并发相关)_第2张图片
      系统内核管理线程(KLT),内核保存线程的状态和上下文信息,线程阻塞不会引起进程阻塞。在多处理器系统上,多线程在多处理器上并行运行。线程的创建、调度和管理由内核完成,效率比ULT要慢,比进程操作快。

    • 用户线程模型 (ULT):
      从零开始的java面试题(【2021】3.线程、并发相关)_第3张图片
      用户线程(ULT):用户程序实现,不依赖操作系统核心,应用提供创建、同步、调度和管理线程的函数来控制用户线程。不需要用户态/内核态切换,速度快。内核对ULT无感知,线程阻塞则进程阻塞(包括它的所有线程)。

 

1.线程有几种状态?(生命周期)

从零开始的java面试题(【2021】3.线程、并发相关)_第4张图片

线程通常有五种状态:

  • 新建状态(New): 新创建了一个线程对象。
  • 就绪状态(Runnable): 线程对象创建后,其他线程调用了该对象的start方法。该状态的线程位于可运行线程池中,变得可运行,等待获取CPU的使用权。(注:若加了锁,则只有争夺到锁的才会进入就绪)
  • 运行状态(Running): 就绪状态的线程获取了CPU,执行程序代码。
  • 阻塞状态(Blocked): 阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。
  • 死亡状态(Dead): 线程执行完了或者因异常退出了run方法,该线程结束生命周期。

阻塞的情况又分为三种:

  • 等待阻塞: 运行的线程执行wait方法,该线程会释放占用的所有资源,JVM会把该线程放入 “等待池” 中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify或notifyAll方法才能被唤醒。(注:wait是object类的方法)
  • 同步阻塞: 运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入 “锁池” 中。
  • 其他阻塞: 运行的线程执行sleep或join方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep状态超时、join等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

锁池及等待池说明:

  • 锁池:
    所有需要竞争同步锁的线程都会放在锁池当中,比如当前对象的锁已经被其中一个线程得到,则其他线程需要在这个锁池进行等待,当前面的线程释放同步锁后锁池中的线程去竞争同步锁,当某个线程得到锁后会进入就绪队列进行等待cpu资源分配。
  • 等待池:
    当我们调用wait() 方法后,线程会放到等待池当中,等待池的线程是不会去竞争同步锁。只有调用了notify() 或notifyAll() 后等待池的线程才会开始去竞争锁,notify() 是随机从等待池选出一个线程放到锁池,而notifyAll()是将等待池的所有线程放到锁池当中。

 

2.sleep()、wait()、join()、yield()的区别

sleep 和 wait 的区别:

  • ① sleep 是 Thread 类的静态本地方法,wait 则是 Object 类的本地方法。
  • sleep方法不会释放lock,但是wait会释放,而且会加入到等待队列中。

sleep可以理解为一个人在睡觉时,把锁捏在了手里,这样导致其他人也无法获取锁。而wait则是一个人临时有事,把锁让出来给其他人。(如果在睡眠期间其他线程调用了这个线程的interrupt方法,那么这个线程也会抛出interruptexception异常返回,这点和wait是一样的)

  • ③ sleep方法不依赖于同步器synchronized,但是wait需要依赖synchronized关键字(包括notify)。
  • ④ sleep不需要被唤醒(休眠结束后进入就绪状态),但是wait需要 (实际上已有wait(long timeout)方法设置超时)
  • ⑤ sleep 一般用于当前线程休眠,或者轮循暂停操作,wait 则多用于多线程之间的通信(例A线程用notify唤醒了B线程)。
  • sleep 会让出 CPU 执行时间且强制上下文切换,而 wait 则不一定,wait 后马上notify 可能还是有机会重新竞争到锁继续执行的。

yield():

  • 执行后线程直接进入就绪状态,马上释放了cpu的执行权,但是依然保留了cpu的执行资格,所以有可能cpu下次进行线程调度还会让这个线程获取到执行权继续执行。(锁还在手里,没有进入锁池,仅仅是给其他线程先执行程序的机会)

个人感觉yield 方法意义不是很大,在就绪状态的线程,还是会去争夺cpu调度。或许只有AbstractQueuedSynchronizer(AQS)这类需要自旋等待的功能较适用。

join():

  • 执行后线程进入阻塞状态,例如在线程B中调用线程A的join(),那线程B会进入到阻塞队列,直到线程A结束或中断线程后,重新回到就绪状态。
public static void main(String[] args) throws InterruptedException {
     
        Thread t1 = new Thread(() -> {
     
            try {
     
                Thread.sleep(3000);
            } catch (InterruptedException e) {
     
                e.printStackTrace();
            }
            System.out.println("子线程输出");
        });
        t1.start();
        t1.join();
        // 这行代码必须要等t1全部执行完毕,才会执行
        System.out.println("主线程输出");
    }

 

3.对线程安全的理解

说明:不是线程安全、应该是内存安全,堆是共享内存,可以被所有线程访问。

当多个线程访问一个对象时,如果不用进行额外的同步控制或其他的协调操作,调用这个对象的行为都可以获得正确的结果,我们就说这个对象是线程安全的。

堆:进程和线程共有的空间,分全局堆和局部堆。全局堆就是所有没有分配的空间,局部堆就是用户分配的空间。堆在操作系统对进程初始化的时候分配,运行过程中也可以向系统要额外的堆,但是用完了要还给操作系统,要不然就是内存泄漏。

在Java中,堆是Java虚拟机所管理的内存中最大的一块,是所有线程共享的一块内存区域,在虚拟机启动时创建。堆所存在的内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

栈:每个线程独有的,保存其运行状态和局部自动变量的。栈在线程开始的时候初始化,每个线程的栈互相独立,因此,栈是线程安全的(多线程操作堆才会有所谓的线程不安全)。操作系统在切换线程的时候会自动切换栈。(栈空间不需要在高级语言里面显式的分配和释放)

目前主流操作系统都是多任务的,即多个进程同时运行。为了保证安全,每个进程只能访问分配给自己的内存空间,而不能访问别的进程的,这是由操作系统保障的。

在每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以访问到该区域,这就是造成问题的潜在原因。

 

4.对守护线程的理解

守护线程: 为所有非守护线程提供服务的线程(即任何一个守护线程都是整个JVM中所有非守护线程的保姆)

守护线程类似于整个进程的一个默默无闻的小喽喽;它的生死无关重要,它却依赖整个进程而运行;哪天其他线程结束了,没有要执行的了,程序就结束了,理都没理守护线程,就把它中断了。

感觉不靠谱?守护线程其实也有优点(作用):
GC垃圾回收线程:就是一个经典的守护线程。当我们的程序中不再有任何运行的Thread,程序就不会再产生垃圾,垃圾回收器也就无事可做,所以当垃圾回收线程是JVM上仅剩的线程时,垃圾回收线程会自动离开。(它始终在低级别的状态中运行,用于实时监控和管理系统中的可回收资源。)

应用场景:

  • ① 为其它线程提供服务支持的情况。
  • ② 或者在任何情况下,希望程序结束时,某线程必须正常且立刻关闭,就可以作为守护线程来使用。

反例:如果一个正在执行某个操作的线程必须要正确地关闭掉否则就会出现不好的后果的话,那么这个线程就不能是守护线程,而是用户线程。通常都是些关键的事务,比方说,数据库录入或者更新,这些操作都是不能中断的。

注意点:

  • thread.setDaemon(true)必须在thread.start()之前设置,否则会抛出一个IllegalThreadStateException异常。你不能把正在运行的常规线程设置为守护线程。
  • 在Daemon线程中产生的新线程也是Daemon的。
  • 守护线程不应用于去访问固有资源,比如读写操作或者计算逻辑。因为它会在任何时候甚至在一个操作的中间发生中断。
  • Java自带的多线程框架,比如ExecutorService,会将守护线程转换为用户线程,所以如果要使用守护线程就不能用Java的线程池。

 

5.ThreadLocal的原理和使用场景

需知: 每一个 Thread 对象均含有一个 ThreadLocal.ThreadLocalMap类型的成员变量,它存储本线程中所有ThreadLocal对象及其对应的值。

ThreadLocalMap (简单理解为Map结构) 由一个个 Entry 对象构成,一个 Entry 由 ThreadLocal 对象和 Object 构成。由此可见, Entry 的key是ThreadLocal对象,并且是一个弱引用。当没指向key的强引用后,该key就会被垃圾收集器回收。

使用说明:

  • set(): 执行set方法时,ThreadLocal首先会获取当前线程对象,然后获取当前线程的ThreadLocalMap对象,判断ThreadLocalMap是否为null,存在则覆盖key_value,否则创建key_value。(说白了就是将key_value设置给当前线程)
    • key : 当前ThreadLocal对象。
    • value:我们设置的值。
  • get(): 执行过程类似。ThreadLocal首先会获取当前线程对象,然后获取当前线程的ThreadLocalMap对象。再以当前ThreadLocal对象为key,获取对应的value。

由于每一条线程均含有各自私有的ThreadLocalMap容器,这些容器相互独立互不影响,因此不会存在线程安全性问题,从而也无需使用同步机制来保证多条线程访问容器的互斥性。

使用场景:

  • ① 在进行对象跨层传递的时候,使用ThreadLocal可以避免多次传递,打破层次间的约束。(比如Controller要传user到Service层,用参数传太冗余了)
  • ② 线程间数据隔离。(只有当前线程能访问到各自的数据)
  • ③ 进行事务操作,用于存储线程事务信息。
  • ④ 数据库连接,Session会话管理。

Spring框架在事务开始时会给当前线程绑定一个Jdbc Connection,在整个事务过程都是使用该线程绑定的connection来执行数据库操作,实现了事务的隔离性。Spring框架里面就是用的ThreadLocal来实现这种隔离。

 

6.强引用,软引用,弱引用

强引用: 使用最普遍的引用(new),一个对象具有强引用,不会被垃圾回收器回收。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种对象。(例 User a = new User(); a 就是对 堆中new User的强引用)

如果想取消强引用和某个对象之间的关联,可以显式地将引用赋值为null,这样可以使JVM在合适的时间就会回收该对象。

软引用: JVM进行垃圾回收时,正常情况不会被回收,但是GC做完后发现释放不出空间存放新的对象,则会把这些软引用的对象回收掉。在java中用 SoftReference类表示。(可用在浏览器的后退,前进)

弱引用: JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在java中,用 WeakReference类来表示。(较少用。没有外部强引用它时,gc回收一定会收拾它)

注意:WeakReference引用本身是强引用,它内部的(T reference)才是真正的弱引用字段,WeakReference就是一个装弱引用的容器而已。使用时,将对象用WeakReference软引用类型的对象包裹即可

虚引用: 幽灵引用,几乎不用。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知(将其与ReferenceQueue关联)。在java中用 PhantomReference类表示。

案例:

/**
 * @author wook
 * @date 2021/5/11 16:54
 */
public class Refrence {
     
    public static void main(String... args) {
     

        RefrenceStuduet studentA = new RefrenceStuduet("AA", 1);
        RefrenceStuduet studentB = new RefrenceStuduet("BB", 2);
        RefrenceStuduet studentC = new RefrenceStuduet("CC", 3);

        //强引用strongStudentA
        RefrenceStuduet strongStudentA = studentA;
        //软引用softStudentB
        SoftReference<RefrenceStuduet> softStudentB = new SoftReference<>(studentB);
        //弱引用weekStudentC
        WeakReference<RefrenceStuduet> weekStudentC = new WeakReference<>(studentC);
        //直接弱引用
        WeakReference<RefrenceStuduet> weekStudentD = new WeakReference<>(new RefrenceStuduet("DD", 4));

        //引用都变为null
        studentA = null;
        studentB = null;
        studentC = null;

        System.out.println("Before gc..."); 
        System.out.println(String.format("strongA = %sn, softB = %s, weakC = %s, weakD = %s", strongStudentA, softStudentB.get(), weekStudentC.get(), weekStudentD.get()));
        System.gc();   //执行系统gc
        System.out.println("After gc...");
        System.out.println(String.format("strongA = %s, softB = %s, weakC = %s, weakD = %s", strongStudentA, softStudentB.get(), weekStudentC.get(), weekStudentD.get()));
    }
}

@Data
@AllArgsConstructor
class RefrenceStuduet {
     
    private String name;
    private int age;
}
结果:
Before gc...
strongA = RefrenceStuduet(name=AA, age=1)n, softB = RefrenceStuduet(name=BB, age=2), weakC = RefrenceStuduet(name=CC, age=3), weakD = RefrenceStuduet(name=DD, age=4)
After gc...
strongA = RefrenceStuduet(name=AA, age=1), softB = RefrenceStuduet(name=BB, age=2), weakC = null, weakD = null

案例说明:

  • 强引用代码: 假设 studentA 指向的其实是 堆内存地址 ‘666’,当执行 RefrenceStuduet strongStudentA = studentA; 相当于strongStudentA 也指向了内存地址 ‘666’,而执行 studentA = null; 相当于将studentA 指向的‘666’地址断开。而strongStudentA 仍然指向‘666’,所以strongStudentA 不会被gc回收。
  • 软引用代码: 前面概念说了,gc时发现内存足够,虽然studentB = null,但也不会回收softStudentB 。
  • 弱引用代码: 案例中studentC = null,且没有其他外部强引用,根据概念可知,gc一执行就回收了。

 

7.ThreadLocal内存泄露原因,如何避免?

内存泄露: 指程序在申请内存后,无法释放已申请的内存空间。(一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光)

不再会被使用的对象或者变量占用的内存不能被gc回收,就是内存泄露。

开始说说ThreadLocal内存泄露原因:
从零开始的java面试题(【2021】3.线程、并发相关)_第5张图片
需知: Thread.ThreadLocalMap的Key为弱引用,如果一个ThreadLocal不存在外部强引用时(例显示设置为null),Key(ThreadLocal)势必会被GC回收 ,这样就会导致ThreadLocalMap中key为null, 而value还存在着强引用,只有thead线程退出以后,value的强引用链条才会断掉,但如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链。(红色链条强引用,虚线为弱引用)

既然会出现上面所述问题,为什么java要将其Key设置为弱引用?

  • 当key 使用强引用:
    当ThreadLocalMap的key为强引用回收ThreadLocal时,因为ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。

  • 当key 使用弱引用:
    当ThreadLocalMap的key为弱引用回收ThreadLocal时,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。当key为null,在下一次ThreadLocalMap调用set(),get(),remove()方法的时候会被清除value值。

总结: ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。(个人认为设置为弱引用,反而更能避免内存泄露)

ThreadLocal正确的使用方法:

  • 每次使用完ThreadLocal都调用它的remove()方法清除数据。
  • 将ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉 。

补充:Java为了最小化内存泄露的可能性和影响,在ThreadLocal的get、set的时候,都会检查当前key所指的对象是否为null,是则删除对应的value,让它能被GC回收。

弱引用demo: (懂的跳过)

public class ThreadLocalDemo {
     
    public static void main(String[] args) throws InterruptedException {
     
      	firstStack();
        Thread thread = Thread.currentThread();
        System.out.println("GC前:" + thread); // 在这里打断点,观察thread对象里的ThreadLocalMap数据
        System.gc();
        TimeUnit.SECONDS.sleep(1);
        System.out.println("GC后:" + thread); // 在这里打断点,观察thread对象里的ThreadLocalMap数据
    }
    private static A firstStack(){
     
        A a = new A();
        System.out.println("value: "+ a.get());
        return a;
    }
    
    private static class A{
     
    	// 如果此处加了 static ,ThreadLocalMap中referent也不会被回收(可以理解为System.gc回收的是堆,而方法区不会被回收)       
    	private ThreadLocal<String> local = ThreadLocal.withInitial(() -> "in class A");

        public String get(){
     
            return local.get();
        }
        public void set(String str){
     
            local.set(str);
        }
 		@Override 
        protected void finalize() throws Throwable {
     
            System.out.println("我(A)被回收了(该方法第2篇文章有提到)");
        }
    }
}

结果: ThreadLocalMap中的referent为null(即local被回收),除非将 firstStack(); 改为 A a = firstStack();

说明:
上面的代码,当构造一个A对象时,内部的local对象也构造了,之后调用get和set方法堆local对象取值和设置值,当A对象不可达时,垃圾收集器就会回收A。


现在我们假设ThreadLocalMap的Entey里的key(ThreadLocal对象)不是弱引用的,且已经调用了A的对象的get或set方法,那么垃圾收集器回收A对象时,一定不会回收里面的local对象,为什么?

因为Entey已近持有了local对象的引用,我们没有设置引用类型,那这个引用就默认是个强引用。
Thread -> ThreadLocal.ThreadLocalMap -> Entry[] -> Enrty -> key(threadLocal对象)和value

引用链如上面所示,这个引用链全是强引用,当这个线程还未结束时,他持有的强引用,包括递归下去的所有强引用都不会被垃圾回收器回收。


那么回到正常情况,ThreadLocalMap里Entey的key是弱引用,在本例中也就是local对象在这里是弱引用,当对象A回收时,由于local对象只剩下被弱引用key所引用,所以local对象也会被回收。

结论: 被回收之前,内存泄漏确实存在(key不见了导致无法访问存在的 value),但是调用get,set等都会清理泄漏的Entry。

 

8.并发的三大特性

需知: Java内存模型(JMM):每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。

  • 原子性: 解决关键字 synchronized,ReentrantLock等加锁操作等

原子性是指在一个操作中cpu不可以在中途暂停然后再调度,即不被中断操作,要不全部执行完成,要不都不执行。就好比转账,从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。2个操作必须全部完成。

private long count = 0;
public void calc() {
     
	count++;
}

count++ 即 count = count + 1,实际上执行分为四步
1:将 count 从主存读到工作内存中的副本中
2+1的运算
3:将结果写入工作内存
4:将工作内存的值刷回主存(什么时候刷入由操作系统决定,不确定的)

案例说明: 程序中原子性指的是最小的操作单元,比如自增操作,它本身其实并不是原子性操作,分了3步的,包括读取变量的原始值、进行加1操作、写入工作内存。所以在多线程中,有可能一个线程还没自增完,可能才执行到第二部,另一个线程就已经读取了值,导致结果错误。那如果我们能保证自增操作是一个原子性的操作,那么就能保证其他线程读取到的一定是自增后的数据。

但是还要处理可见性问题,不然即使我们令123 变为原子性,2个线程各自执行完 123原子操作后,在第4步2个线程各自向主存中写入结果 ‘1’,结果也是不对的。

  • 可见性: 解决关键字 volatile、synchronized、final

当多个线程访问同一个变量时,其中一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

  • 了解两个概念: 总线Lock 和 MESI缓存一致性协议

结合上一个案例说明:若两个线程在不同的cpu,那么线程1改变了i的值还没刷新到主存,线程2又使用了i,那么这个i值肯定还是之前的,线程1对变量的修改线程没看到这就是可见性问题。

//线程1
boolean stop = false;
while(!stop){
     
doSomething();
}
//线程2
stop = true;

如果线程2改变了stop的值,线程1一定会停止吗?不一定。当线程2更改了stop变量的值之后,但是
还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
  • 有序性: 解决关键字 volatile、synchronized

说明: 虚拟机在进行代码编译时,对于那些改变顺序之后不会对最终结果造成影响的代码,虚拟机不一定会按照我们写的代码的顺序来执行,有可能将他们重排序。(单线程场景不会出问题,多线程会)

/**
* 单例模式:双重校验锁(DCL)
*/
1 public class Singleton {
     
2        private volatile static Singleton singleton;// 通过volatile关键字来确保安全
3        private Singleton(){
     }
4        public static Singleton getInstance(){
     
5            if(singleton == null){
     
6                synchronized (Singleton.class){
     
7                    if(singleton == null){
     
8                        singleton = new Singleton();
9                    }
10                }
11            }
12            return singleton;
13        }
    }

案例说明: 首先要知道,new Singleton()实际上分为三步操作:
(1)分配内存空间
(2)初始化对象
(3)将内存空间的地址赋值给对应的引用

2 3会被处理器优化,发生重排序

当不加volatile修饰,如若A线程new Singleton发生指令重排

  • ① A线程进入了锁,走到第8行。先执行第3步操作(将分配的内存空间引用赋值给了静态属性singleton,即singleton 已经!= null),但实际上第2步操作对象还未初始化
  • ② 此时CPU调度到B线程,直接执行到第5行,发现singleton已经不为“空”了然后直接就跳转到12行返回,如果此时B线程拿这个空对象去执行,你说会不会空指针?

总结:

  • synchronized关键字同时满足以上三种特性,但是volatile关键字不满足原子性。在某些情况下,volatile的同步机制的性能确实要优于锁(synchronized关键字或java.util.concurrent包里面的锁),因为volatile的总开销要比锁低。

synchronized 的有序性是持有相同锁的两个同步块只能串行的进入,即被加锁的内容要按照顺序被多个线程执行,但是其内部的同步代码还是会发生重排序,使块与块之间有序可见。
 
volatile的有序性是通过插入内存屏障来保证指令按照顺序执行。不会存在后面的指令跑到前面的指令之前来执行。是保证编译器优化的时候不会让指令乱序。

  • 我们判断使用volatile还是加锁的唯一依据就是volatile的语义能否满足使用的场景(例2个线程中,都只用了 AtomicInteger等原子操作,那么就可以用volatile来保证有序性和可见性)

补充:
1.AtomicInteger 是基于 volatile(保证可见+有序) 和 CAS (保证原子)保证线程安全。
2.Unsafe 的 loadFence等方法也可防止指令重排,compareAndSwapObject 方法就是 CAS。
3.当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中。

 

9.为什么用线程池?解释下线程池参数?

为什么用线程池(好处):

  • 复用存在的线程,减少线程创建,消亡的开销。(前面提了,上下文切换是重型操作)
  • 提高响应速度。当任务到达时,直接有线程可用执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

工作流程图:
从零开始的java面试题(【2021】3.线程、并发相关)_第6张图片
流程概图说明:
① 任务进来,线程池会创建核心线程执行任务,最多创建corePoolSize个核心线程。
② 当任务比较多时,核心线程无法处理完,会将其存放到队列中,直到队列放满。
③ 当队列放满后,此时会创建临时线程处理任务,最多创建maxinumPoolSize - corePoolSize个临时线程。
④ 如果线程池已经没有能力在处理新提交的任务,触发‘拒绝策略’。

线程池ThreadPoolExecutor参数介绍:

  • corePoolSize 代表核心线程数,也就是正常情况下创建工作的线程数,这些线程创建后并不会消除,而是一种常驻线程
  • maxinumPoolSize 代表的是最大线程数,它与核心线程数相对应,表示最大允许被创建的线程数,比如当前任务较多,将核心线程数都用完了,还无法满足需求时,此时就会创建新的线程,但是线程池内线程总数不会超过最大线程数
  • keepAliveTime 、 unit 表示超出核心线程数之外的线程的空闲存活时间,也就是核心线程不会消除,但是超出核心线程数的部分线程如果空闲一定的时间则会被消除,我们可以通过setKeepAliveTime 来设置空闲时间
  • workQueue 用来存放待执行的任务,假设我们现在核心线程都已被使用,还有任务进来则全部放入队列,直到整个队列被放满但任务还再持续进入则会开始创建新的临时线程
    • ① ArrayBlockingQueue:基于数组结构的有界阻塞队列,按FIFO排序任务;
    • ② LinkedBlockingQuene:基于链表结构的阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQuene;
    • ③ SynchronousQuene:一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQuene;
    • ④ priorityBlockingQuene:具有优先级的无界阻塞队列;
  • ThreadFactory 实际上是一个线程工厂,用来生产线程执行任务。我们可以选择使用默认的创建工厂,产生的线程都在同一个组内,拥有相同的优先级,且都不是守护线程。当然我们也可以选择自定义线程工厂,一般我们会根据业务来制定不同的线程工厂
  • Handler 任务拒绝策略,有两种情况,第一种是当我们调用 shutdown 等方法关闭线程池后,这时候即使线程池内部还有没执行完的任务正在执行,但是由于线程池已经关闭,我们再继续想线程池提交任务就会遭到拒绝。另一种情况就是当达到最大线程数,线程池已经没有能力继续处理新提交的任务时,这是也就拒绝
    • ① AbortPolicy:直接抛出异常,默认策略;
    • ② CallerRunsPolicy:用调用者所在的线程来执行任务;
    • ③ DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
    • ④ DiscardPolicy:直接丢弃任务;

 

10.线程池中阻塞队列的作用?为什么是先添加列队而不是先创建最大线程?

阻塞队列的作用(为什么要使用阻塞队列):

  • 一般的队列只能保证作为一个有限长度的缓冲区,如果超出了缓冲长度,就无法保留当前的任务了,阻塞队列通过阻塞可以保留住当前想要继续入队的任务。
  • 阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,使得线程进入wait状态,释放cpu资源。
  • 阻塞队列自带阻塞和唤醒的功能,不需要额外处理,无任务执行时,线程池利用阻塞队列的take方法挂起,从而维持核心线程的存活、不至于一直占用cpu资源

为什么是先添加列队而不是先创建最大线程?

需知: 首先要知道,如果反过来设计,先创建最大线程,再添加进队列也是没问题的。
线程池先添加队列的设计目的:
线程池在创建新线程的时候,是要获取全局锁的,这个时候其它的就得阻塞,影响了整体效率。

主要原因:出于线程池创建,销毁开销大的考虑。

大白话说明线程池流程: 就好比一个企业里面有10个(core)正式工的名额,最多招10个正式工,要是任务超过正式工人数(task > core)的情况下,工厂领导(线程池)不是首先扩招工人,还是这10人,但是任务可以稍微积压一下,即先放到队列去(代价低)。10个正式工慢慢干,迟早会干完的,要是任务还在继续增加,超过正式工的加班忍耐极限了(队列满了),就的招外包帮忙了(注意是临时工)要是正式工加上外包还是不能完成任务,那新来的任务就会被领导拒绝了(线程池的拒绝策略)。

 

11.线程池中线程复用原理

需知: 线程池将线程和任务进行解耦,线程是线程,任务是任务,摆脱了之前通过 Thread 创建线程时的一个线程必须对应一个任务的限制。

原理: 在线程池中,同一个线程可以从阻塞队列中不断获取新任务来执行,其核心原理在于线程池对Thread 进行了封装,并不是每次执行任务都会调用 Thread.start() 来创建新线程,而是让每个线程去执行一个“循环任务”,在这个“循环任务”中不停检查是否有任务需要被执行,如果有则直接执行

也就是每个线程去调用任务中的 run 方法,将 run 方法当成一个普通的方法执行,通过这种方式只使用固定的线程就将所有任务的 run 方法串联起来。

你可能感兴趣的:(从零开始的java面试题,多线程,面试,java)