面试知识点梳理及相关面试题(五)-- 多线程

2023-11-18

1.进程、线程和协程

  • 进程:
    • 一个运行的程序就是一个进程
    • 进程是资源分配的最小单位
  • 线程:
    • 程序中运行的一个个子任务就是一个线程
    • 线程是操作系统调度执行的最小单位
  • 协程:
    • 协程是一种用户态的轻量级线程,协程的调度完全由用户控制。

2.创建线程的四种方式:

  1. 继承Thread类,子类对象.start()启动
  2. 实现Runnable方法,创建Thread对象传入目标对象最后Thread对象.start()启动
  3. 实现Callable方法(有返回值),call方法启动
  4. 使用线程池创建

2.1 使用Runnable相比Thread的好处:

继承只能是单继承,具有局限性

2.2 Callable和Runnable的区别:

  • Runnable 接口 run 方法无返回值;Callable 接口 call 方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
  • Runnable 接口 run 方法只能抛出运行时异常,且无法捕获处理;Callable 接口 call 方法允许抛出异常,可以获取异常信息

注:Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。

2.3 为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?

new 一个 Thread,线程进入了新建状态。调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。

而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结: 调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。

3.线程的状态:

  1. 新建new
  2. 可运行runable(可能在运行,也可能在等cpu分配时间片)
  3. 阻塞blocked
  4. 等待waiting
  5. 超时等待timed_waiting
  6. 终止terminated
    在这里插入图片描述

4.Thread类及其相关方法:

  • 获取线程本身信息:
// 表示的线程是线程实例本身
this.XXX() 
// 表示的线程是正在执行Thread.currentThread.XXX()所在代码块的线程
Thread.currentThread.XXX()Thread.XXX() 

4.1 线程中实例方法:

  • start:启动线程
  • run:线程执行,执行的是run方法中的内容
  • isAlive:判断线程是否处于活跃状态
  • getPriority()和setPriority(int newPriority)
    • 获取线程优先级,
    • 优先级高的得到CPU资源的概率更高
    • 优先级具有继承性
  • isDaeMon、setDaemon(boolean on):守护线程
    • 最典型的守护线程是GC线程
    • 为了给其他线程提供便利
    • 被守护线程销毁了,它也跟着销毁了
    • setDaemon(true)必须在线程start()之前
  • interrupt:中断线程(事实上它并不能终止线程
    • 实际作用是:在线程受到阻塞时抛出一个中断信号,这样线程就能获得这个状态(isInterrupted或者interrupted方法)得以退出阻塞状态
  • join:
    • 使这个线程的父线程(调用方)无限阻塞直到这个线程执行完成
    • 内部其实还是通过wait方法实现
    • 并不能阻塞同级的线程
    • 会释放锁,因为内部调用的是wait方法
  • isInterrupted:获取线程中断状态

4.2 线程中的静态方法:

  • currentThread:返回当前正在执行的线程的对象的引用
  • sleep:
    • 使当前线程休眠一段时间,进入等待状态
    • 不会释放锁
    • sleep时间到了之后,进入可运行状态
    • sleep(0)和yield方法有点像,都是让cpu重新分配时间片
  • yield:
    • 暂停当前线程,进入可运行状态,
    • 让cpu重新分配时间片,有可能这个线程又会分到时间片继续执行
  • interrupted:拿到线程中断状态,如果处于中断状态,就将中断状态设置为false
4.2.1 为什么这些方法是静态的?

Thread类中的静态方法表示操作的线程是"正在执行静态方法所在的代码块的线程"。为什么Thread类中要有静态方法,这样就能对CPU当前正在运行的线程进行操作。

注意:是对当前正在运行的线程进行操作!!!

ThreadLocal:

ThreadLocal 是一个本地线程副本变量工具类,在每个线程中都创建了一个 ThreadLocalMap 对象,简单说 ThreadLocal 就是一种以空间换时间的做法,每个线程可以访问自己内部 ThreadLocalMap 对象内的 value。通过这种方式,避免资源在多线程间共享。

原理:线程局部变量是局限于线程内部的变量,属于线程自身所有,不在多个线程间共享。Java提供ThreadLocal类来支持线程局部变量,是一种实现线程安全的方式。但是在管理环境下(如 web 服务器)使用线程局部变量的时候要特别小心,在这种情况下,工作线程的生命周期比任何应用变量的生命周期都要长。任何线程局部变量一旦在工作完成后没有释放,Java 应用就存在内存泄露的风险。

使用场景:

经典的使用场景是为每个线程分配一个 JDBC 连接 Connection。这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B线程正在使用的 Connection; 还有 Session 管理 等问题。

TheadLocal内存泄漏:

ThreadLocal造成内存泄漏的原因?
  1. 没有手动删除这个 Entry
    • 只要在使用完下 ThreadLocal ,调用其 remove 方法删除对应的 Entry ,就能避免内存泄漏。
  2. CurrentThread当前线程依然运行
    • 由于ThreadLocalMap 是 Thread 的一个属性,被当前线程所引用,所以ThreadLocalMap的生命周期跟 Thread 一样长。如果threadlocal变量被回收,那么当前线程的threadlocal变量副本指向的就是key=null, 也即entry(null,value),那这个entry对应的value永远无法访问到。实际私用ThreadLocal场景都是采用线程池,而线程池中的线程都是复用的,这样就可能导致非常多的entry(null,value)出现,从而导致内存泄露

综上, ThreadLocal 内存泄漏的根源是:
由于ThreadLocalMap 的生命周期跟 Thread 一样长,对于重复利用的线程来说,如果没有手动删除(remove()方法)对应 key 就会导致entry(null,value)的对象越来越多,从而导致内存泄漏

为什么要使用弱引用:
  1. 如果key是强引用:
    • 因为如果key设计成强引用且没有手动remove(),那么key会和value一样伴随线程的整个生命周期。
    • 假设在业务代码中使用完ThreadLocal,ThreadLocal ref应该被回收了,但是因为threadLocalMap的Entry强引用了threadLocal(key就是threadLocal), 造成threadLocal无法被回收。在没有手动删除Entry以及CurrentThread(当前线程)依然运行的前提下, 始终有强引用链CurrentThread Ref → CurrentThread →Map(ThreadLocalMap)-> entry, Entry就不会被回收( Entry中包括了ThreadLocal实例和value),导致Entry内存泄漏也就是说: ThreadLocalMap中的key使用了强引用, 是无法完全避免内存泄漏的。
  2. 为什么 key 要用弱引用:
    • 在 ThreadLocalMap 中的set/getEntry 方法中,会对 key 为 null(也即是 ThreadLocal 为 null )进行判断,如果为 null 的话,那么会把 value 置为 null。这就意味着使用threadLocal , CurrentThread依然运行的前提下,就算忘记调用 remove 方法,弱引用比强引用可以多一层保障:弱引用的 ThreadLocal 会被回收.对应value在下一次 ThreadLocaI 调用 get()/set()/remove() 中的任一方法的时候会被清除,从而避免内存泄漏
ThreadLocal内存泄漏解决方案?

每次使用完ThreadLocal,都调用它的remove()方法,清除数据。

在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用ThreadLocal就跟加锁完要解锁一样,用完就清理。

5.sychronized:

  • 可以修饰方法,类,代码块
  • 接口上不能加此关键字
  • JVM实现的锁,在锁住的字节码前后添加monitorEnter和monitorExit
    • 为什么会有两个monitorexit?
      这个主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁。
      仅有ACC_SYNCHRONIZED这么一个标志,该标记表明线程进入该方法时,需要monitorenter,退出该方法时需要monitorexit。
  • 既保证原子性,也保证可见性

5.1 修饰普通方法时

  • 锁住的是对象
    • A线程持有Object对象的Lock锁,B线程可以以异步方式调用Object对象中的非synchronized类型的方法
    • A线程持有Object对象的Lock锁,B线程如果在这时调用Object对象中的synchronized类型的方法则需要等待,也就是同步
  • 没有继承性,父类方法是同步的,子类中此方法默认不是同步的
  • 构造方法不能加sychronized,但是可以通过同步代码块来实现同步
  • 锁重入:已经有了一个对象锁,想要再次获得这个对象锁是可以获得的。这种锁重入的机制,也支持在父子类继承的环境中。
  • 出现异常自动释放锁

5.2 修饰类方法、类时:

  • 修饰静态方法或者类时,锁住的是class对象,即对当前java文件对应的class类加锁
    • 类锁和对象锁不是同一个锁,可以异步执行
    • 类锁是对静态方法使用synchronized关键字后,多线程无论是访问单个对象还是多个对象的sychronized块,都是同步的
  • 类锁无论创建多少个对象,相互之间都是同步的,对象锁创建多个对象,对象之间就是异步的
class Test {
	synchronized static void printA(){}
}
等价于:
class Test {
	static void printA(){
		synchronized (Test.class) {}
	}
}

5.3 修饰代码块:

  • Java还支持对"任意对象作为对象监视器来实现同步的功能,即可以指定锁住的对象。这个"任意对象"大多数是实例变量及方法的参数,使用格式为synchronized(非this对象)。当使用任意对象作为对象监视器时:
    • 多个线程持有"对象监视器"为同一个对象的前提下,同一时间只能有一个线程可以执行synchronized(非this对象x)代码块中的代码。
    • 如果多个线程持有的“对象监视器”不为同一个,列入是一个创建的局部变量,那此时方法快就是异步执行了。
  • 只锁住加锁的部分:(所以从性能角度来说,这样可以锁住更少的代码)
    • 当A线程访问对象的synchronized代码块的时候,B线程依然可以访问对象方法中其余非synchronized块的部分
    • 当A线程进入对象的synchronized代码块的时候,B线程如果要访问这段synchronized块,那么访问将会被阻塞
  • 如果代码块使用的是当前对象锁synchronized(this){},那么synchronized块和synchronized方法之间同样具体有互斥性

5.4 sychronized原理、轻量级锁、重量级锁、偏向锁、锁升级

查看细读:syschronized相关

6.volatile

6.1 java内存模型JMM

Java的内存模型一个很重要的作用就是规定了:一个线程对共享变量(主内存中的变量)的修改,何时对其他线程可见。

具体来说就是:

  1. 每个线程都有自己的工作内存,称为缓存。
  2. 线程只能操作自己的工作内存,不能直接操作主内存
  3. 共享变量在工作内存中有一个副本
  4. JVM决定何时把工作内存中的副本刷回主内存。
    在这里插入图片描述
6.1.1 JMM与同步的一些约定
  • 原子性 - 保证指令不会受到线程上下文切换的影响
    • 一组操作要么不做,要么保证全部做完,不会被中断
    • volatile关键字无法保证原子性,synchronized可以保证原子性
  • 可见性 - 保证指令不会受 cpu 缓存的影响
    • 一个线程对一个变量进行了修改,另一个线程可以马上感知到这种修改。
    • volatile关键字可以保证可见性,synchronized和final也可以保证可见性
  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响
    • 指令优化(指令重排序):JVM不保证编译后的代码执行的顺序,只保证重排之后的执行结果和重排之前一样。
    • volatile可以防止指令重排序

6.2 volatile使用结论:

  • 保证可见性
    • 加了volatile的对象,会有一个"lock addl $0x0,(%esi)"(把esp寄存器的值加0)指令,这个指令的作用是把本CPU的cache写入内存,这个写入动作也会导致其他的缓存(其他的线程所拥有的本地内存)无效,必须去主内存中读取新的数据值,即让volatile的修改让其他cpu(线程)可见
  • 保证有序性(禁用指令重排序)
    • 有序性:JVM只保证运行的结果,不保证运行的顺序
    • 比如双检索最后创建对象的过程instance = new Instance(),实际上是三步指令:
      1. 给instance 分配内存
      2. 初始化对象instance
      3. 将instance指向刚刚分配的地址
      • 经过指令重排有可能本应是1>2>3,变成了1>3>2
      • 如果此时刚好有另一个线程过来判断,instance == null,会判断为false,因为它已经被分配了地址,所以对象明明没有被创建完成,然后还是返回了对象
    • 经过汇编语言的查询:方法在赋值后(mov %eax…即赋值语句)多执行了一个:lock addl $0x0,(%esi)的操作,这个操作的作用相当于一个内存屏障,指重排序时不能把后面的指令重排序到内存屏障之前的位置
  • 不保证原子性
    • 比如i++一行java代码,转变成字节码指令其实是4行指令:
      • 取数放到操作数栈顶,这个数被volatile保证了和其他线程拿到的是一样的
      • 然后对栈顶这个数据进行add操作
    • 假如add操作时同时进行的,比如2个线程同时拿到了i=1,同时进行add操作,那么本应该i=3,这样i就=2,所以并不能保证原子性
  • 不会导致阻塞

6.3 原理:

volatile的底层实现原理是内存屏障,Memory Barrier(Memory Fence)

  • 对volatile变量的写指令后加入写屏障
  • 对volatile变量的读指令前会加入读屏障

6.4 volatile 能使得一个非原子操作变成原子操作吗?

关键字volatile的主要作用是使变量在多个线程间可见,但无法保证原子性,对于多个线程访问同一个实例变量需要加锁进行同步。

虽然volatile只能保证可见性不能保证原子性,但用volatile修饰long和double可以保证其操作原子性

所以从Oracle Java Spec里面可以看到:

  • 对于64位的long和double,如果没有被volatile修饰,那么对其操作可以不是原子的。在操作的时候,可以分成两步,每次对32位操作。
  • 如果使用volatile修饰long和double,那么其读写都是原子操作
  • 对于64位的引用地址的读写,都是原子操作
  • 在实现JVM时,可以自由选择是否把读写long和double作为原子操作
  • 推荐JVM实现为原子操作

查看:volatile详解

7. Java 如何实现多线程之间的通信和协作

可以通过中断 和 共享变量的方式实现线程间的通讯和协作

比如说最经典的生产者-消费者模型:当队列满时,生产者需要等待队列有空间才能继续往里面放入商品,而在等待的期间内,生产者必须释放对临界资源(即队列)的占用权。因为生产者如果不释放对临界资源的占用权,那么消费者就无法消费队列中的商品,就不会让队列有空间,那么生产者就会一直无限等待下去。因此,一般情况下,当队列满时,会让生产者交出对临界资源的占用权,并进入挂起状态。然后等待消费者消费了商品,然后消费者通知生产者队列有空间了。同样地,当队列空时,消费者也必须等待,等待生产者通知它队列中有商品了。这种互相通信的过程就是线程间的协作。

  • Java中线程通信协作的最常见的两种方式:

    • syncrhoized加锁的线程的Object类的wait()/notify()/notifyAll()
    • ReentrantLock类加锁的线程的Condition类的await()/signal()/signalAll()
  • 线程间直接的数据交换:

    • 通过管道进行线程间通信:1)字节流;2)字符流

7.1 wait()和notify()

  • 必须在同步方法(块)中使用,不然会抛出非法的监视器异常,因为要想调用wait()或者notify()方法,必须持有对象的锁(monitor监视器),所以他们只能在同步方法或者块中使用
  • wait()方法可以使调用该线程的方法释放共享资源的锁,然后从运行状态退出,进入等待队列,直到再次被唤醒。释放锁
  • notify()方法可以随机唤醒等待队列中等待同一共享资源的一个线程,并使得该线程退出等待状态,进入可运行状态。不释放锁
    • 不释放锁意味着:即使调用了notify(),wait的线程也不会马上获取对象锁,必须等待notify()方法的线程执行完并释放锁才可以
  • notifyAll()方法可以使所有正在等待队列中等待同一共享资源的全部线程从等待状态退出,进入可运行状态
  • wait(0):查看源码可以看到就是wait()方法,表示无限期等待下去

7.1 sleep() 和 wait() 有什么区别?

两者都可以暂停线程的执行

  • 类的不同:sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。
  • 是否释放锁:sleep() 不释放锁;wait() 释放锁。
  • 用途不同:Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
  • 用法不同:wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)超时后线程会自动苏醒。
  • sleep不需要强制和synchronized配合使用,但wait需要

7.2 为什么线程通信的方法 wait(), notify()和 notifyAll()被定义在 Object 类里?

Java中,任何对象都可以作为锁,并且 wait(),notify()等方法用于等待对象的锁或者唤醒线程,在 Java 的线程中并没有可供任何对象使用的锁,所以任意对象调用方法一定定义在Object类中。

wait(), notify()和 notifyAll()这些方法在同步代码块中调用

有的人会说,既然是线程放弃对象锁,那也可以把wait()定义在Thread类里面啊,新定义的线程继承于Thread类,也不需要重新定义wait()方法的实现。然而,这样做有一个非常大的问题,一个线程完全可以持有很多锁,你一个线程放弃锁的时候,到底要放弃哪个锁?当然了,这种设计并不是不能实现,只是管理起来更加复杂。

8. park和unPark

它们是LockSupport类中的方法,同样是用户线程通信,作用类似于wait和notify

// 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark();

简单点说就是,park和unpark一一对应;可以调用多个park和unpark,比如权是0,调用一次park,权就+1,调用一次unpark,权就-1,当权小于等于0时,程序可以往下执行

8.1 和wait,notify相比:

与Object的wait和notify相比

  • wait,notify和notifyall必须配合Object Monitor一起使用,而unpark不需要
  • park和unpark是以线程为单位来阻塞和唤醒线程,而notify只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程,不那么精确
  • park和unpark可以先unpark,而wait和notify不能先notify

8.ReentrantLock:

  • 持有的是对象监视器(但是和synchronized不太一样),用法类似
  • 可重入
  • 可以设置等待获取锁的超时时间:如果等了设置的时间后还没有获得锁,就不等了,继续往下执行,通过lock.tryLock(time)实现
  • 可以支持多个条件变量
  • 可打断
  • 可以通过添加初始化参数实现公平锁
  • 使用Condition的await和signal方法实现wait和notify的功能
    • 可以创建多个condition实例,实现多路通知
    • 可以通过condition实现有选择的通知
    • await方法释放锁

9.ReentrantLock和synchronized的区别:

  1. API层面:
    synchronized是一个关键字,可以修饰方法、代码块、类。
    ReentrantLock是一个类。

  2. 等待可中断:lock.tryLock()
    等待可中断是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助。
    具体说来,假如业务代码中有两个线程,Thread1 Thread2.假设Thread1获取了对象object的锁,Thread2将等待Thread1释放object的锁。

    1. 使用synchronized。
      如果Thread1不释放,Thread2将一直等待,不能被中断。synchronized也可以说是Java提供的原子性内置锁机制。内部锁扮演了互斥锁(mutual exclusion lock ,mutex)的角色,一个线程引用锁的时候,别的线程阻塞等待。
    2. 使用ReentrantLock。
      如果Thread1不释放,Thread2等待了很长时间以后,可以中断等待,转而去做别的事情。
  3. 可以指定公平锁或者非公平锁

  4. 锁绑定多个条件

    • ReentrantLock可以同时绑定多个Condition对象,只需多次调用newCondition方法即可。同时还可以指通过绑定多个对象来指定唤醒的线程。
    • synchronized中,锁对象的wait和notify() 或notifyAll()方法可以实现一个隐含的条件。但如果要和多于一个的条件关联的时候就不得不额外添加一个锁。
  5. 他们获取的对象监视器是不同的。

  6. synchronized无法获得锁的状态。Lock可以判断是否获取到了锁

  7. synchronized自动释放锁,Lock必须手动释放锁,如果没有手动释放会造成死锁

  8. 二者的锁机制其实也是不一样的。ReentrantLock 底层调用的是 Unsafe 的park 方法加锁,synchronized 操作的应该是对象头中 mark word

10.如何正确的中断线程:

  1. interrupt()方法中断,然后通过获取中断状态让线程自己去决定何时如何中断线程
    • 注意:它并不一定能够中断线程,由线程自己决定
    • 实现:两阶段终止
  2. 通过出现异常停止线程,线程出现异常,将会停止线程并且释放锁
    • Thread.UncaughtExceptionHandler是用于处理未捕获异常造成线程突然中断情况的一个内嵌接口。当一个未捕获异常将造成线程中断的时候,JVM 会使用 Thread.getUncaughtExceptionHandler()来查询线程的 UncaughtExceptionHandler 并将线程和异常作为参数传递给 handler 的 uncaughtException()方法进行处理。

两阶段终止代码:

public class TestInterrupt3 {
    public static void main(String[] args) throws InterruptedException{
        TwoPhaseTermination twoPhaseTermination = new TwoPhaseTermination();
        twoPhaseTermination.start();

        Thread.sleep(3500);
        twoPhaseTermination.stop();
    }
}

@Slf4j
class TwoPhaseTermination {
    private Thread monitor;

    // 启动监控线程
    public void start() {
        monitor = new Thread(() -> {
            while (true) {
                Thread current = Thread.currentThread();
                if (current.isInterrupted()) { // 获取终止状态
                    log.debug("料理后事");
                    break;
                }
                try {
                    Thread.sleep(1000); //情况1
                    log.debug("执行监控记录"); // 情况2
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    // 重新设置打断标记
                    current.interrupt();
                }
            }
        });
        monitor.start();
    }

    public void stop() {
        monitor.interrupt();
    }
}

11.手写一个生产者/消费者模型:

11.1 虚假唤醒:

有不止2条线程进行生产消费,循环判断是否需要生产/消费时,使用了if判断,导致唤醒后继续向下执行,应该使用while循环,让它被唤醒后,重新进入循环判断是否需要生产/消费
虚假唤醒(使用if)的可能错误情况:

  1. 线程0(生产线程)生产了一个商品,唤醒所有线程,随后再次抢占到资源,进入if判断条件为真,进入等待状态
  2. 线程2(生产线程)抢占到资源,进入判断条件为真,进入到if条件中的等待状态
  3. 线程1()抢占到资源消费了一个商品,随机唤醒一个等待的线程,唤醒了线程2
  4. 线程2继续之前刚刚未完成的任务,生产了一个商品,随机唤醒一个等待的线程,唤醒了线程0
  5. 线程0继续执行刚刚未完成的任务,生产了一个商品,此时商品数量为2

11.2 实现流程:

线程A生产,线程B消费,还有一个缓冲区存放商品。
生产者生产时,不能消费,生产者消费时,不能生产。

  • A通过while条件判断缓冲区中商品是不是满足需求了,如果满足需求了就wait等待被消费,如果不满足需求,进去生产,并唤醒所有消费线程进行消费
  • B通过while条件判断缓冲区中是不是没商品消费了,如果没得消费了就wait等待生产,如果有的消费就消费完,并唤醒所有生产线程生产

11.3 通过condition实现,可以执行唤醒具体线程进行生产/消费

12. 无锁并发:CAS比较并交换:

即:Compare And Swap

整个AQS同步组件、Atomic原子类操作等等都是以CAS实现的,甚至ConcurrentHashMap在1.8的版本中也调整为了CAS+Synchronized。可以说CAS是整个JUC的基石。
在这里插入图片描述

  • 比较当前工作内存中的值和主内存中的值:
    • 如果这个值是期望(相等)的,那么就执行;
    • 如果不是,就一直循环(自旋锁),重新从主内存中获取值设置为工作内存,执行时再次比较工作内存和主内存的值。
  • 在CAS中有三个参数:内存值V、旧的预期值A、要更新的值B,当且仅当内存值V的值等于旧的预期值A时才会将内存值V的值修改为B,否则什么都不干
  • JUC下的atomic类都是通过CAS来实现的
  • Unsafe是CAS的核心类,Java无法直接访问底层操作系统,而是通过本地(native)方法来访问。不过尽管如此,JVM还是开了一个后门:Unsafe,它提供了硬件级别的原子操作
    • 通过比较内存中的偏移地址的值,来比较和预期值是否一样
  • 需要配合volatile使用,因为volatile保证了主内存的值在多个工作线程中是可见的(最新的),并且工作线程在获取值时必须从主内存中获取而不是自己的工作内存
    • 注意:volatile只能保证可见性,不能保证多个线程的指令交错(原子性)
  • 适用于线程数少、多核场景
  • 缺点:
    • 循环时间太长:如果自旋一直不成功,那么会一直处于判断,浪费CPU资源
    • 只能保证一个共享变量原子操作:当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。
    • ABA问题

12.1 CAS多核情况下保证原子性实现方式:

12.1.1 总线加锁:

在多核状态下,某个核执行到带lock的指令时,CPU会让总线锁住,当这个核把此指令执行完毕,再开启总线。这个过程中不会被线程的调度机制所打断保证了多个线程对内存操作的准确性,是原子的

12.1.2 缓存加锁:

缓存加锁就是缓存在内存区域的数据如果在加锁期间,当它执行锁操作写回内存时,处理器不再输出LOCK#信号,而是修改内部的内存地址,利用缓存一致性协议来保证原子性。缓存一致性机制可以保证同一个内存区域的数据仅能被一个处理器修改,也就是说当CPU1修改缓存行中的i时使用缓存锁定,那么CPU2就不能同时修改缓存了i的缓存行。

12.2 ABA问题:

如果一个值原来是A,变成了B,然后又变成了A,那么在CAS检查的时候会发现没有改变,但是实质上它已经发生了改变,这就是所谓的ABA问题。
解决办法:通过版本号AtomicStampedReference来解决。修改的时候添加一个预期的版本号,如果这个预期的版本号和维护的版本号不一致,就修改失败

AQS(AbstractQueuedSynchronizer)

1 概述及原理

AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器。

AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态(state=1)。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

1.2 特点:

  • 用 int类型成员变量state 属性来表示资源的状态(分独占模式和共享模式),子类(同步类)需要定义如何维护这个状态,控制如何获取锁和释放锁
    • getState - 获取 state 状态
    • setState - 设置 state 状态
    • compareAndSetState - cas机制设置 state 状态
    • 独占模式是只有一个线程能够访问资源,而共享模式可以允许多个线程访问资源
  • 提供了基于 FIFO 的等待队列(CLH),类似于 Monitor 的 EntryList,来完成获取资源线程的排队工作
  • 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet
private volatile int state;//共享变量,使用volatile修饰保证线程可见性

//返回同步状态的当前值
protected final int getState() {  
        return state;
}
 // 设置同步状态的值
protected final void setState(int newState) { 
        state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

1.3 AQS 对资源的共享方式

AQS定义两种资源共享方式

  • Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:
    • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
    • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
  • Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。Semaphore、CountDownLatch、 CyclicBarrier、ReadWriteLock

ReentrantReadWriteLock 可以看成是组合式,因为ReentrantReadWriteLock也就是读写锁允许多个线程同时对某一资源进行读。

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式(实现AQS找那个的5个对state操作的方法)即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。

13. 多线程协调:CountDownLatch和CyclicBarrier

13.1 CountDownLatch

  • CountDownLatch主要提供的机制是当多个(具体数量等于初始化CountDownLatch时count参数的值)线程都达到了预期状态或完成预期工作时触发事件,其他线程可以等待这个事件来触发自己的后续工作。

  • 值得注意的是,CountDownLatch是可以唤醒多个等待的线程的

  • 到达自己预期状态的线程会调用CountDownLatch的countDown方法,等待的线程会调用CountDownLatch的await方法

  • CountDownLatch其实是很有用的,特别适合这种将一个问题分割成N个部分的场景,所有子部分完成后,通知别的一个/几个线程开始工作。比如我要统计C、D、E、F盘的文件,可以开4个线程,分别统计C、D、E、F盘的文件,统计完成把文件信息汇总到另一个/几个线程中进行处理

  • 我的理解:先初始化CountDownLatch设置一个初始值,定义多少条线程,每当有一个线程完成了自己的任务,调用countDown方法,相当于把自己从CountDownLatch中取出,然后当CountDownLatch中为空时,即所有线程都取出时,调用await方法,通知其他线程开始执行

  • 可以用它实现生产者消费者模型

13.1.1 简单示例:
public static void main(String[] args) throws InterruptedException {
    // 总数是6
    CountDownLatch countDownLatch = new CountDownLatch(6);
    for (int i = 0; i < 6; i++) {
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "go out");
            countDownLatch.countDown(); // 数量-1
        }, String.valueOf(i)).start();
    }
    // 等待计数器归零,然后向下执行
    countDownLatch.await();

    System.out.println("close door");
}

13.2 CyclicBarrier

  • CyclicBarrier从字面理解是指循环屏障,它可以协同多个线程,让多个线程在这个屏障前等待,直到所有线程都达到了这个屏障时,再一起继续执行后面的动作。
  • 通过await方法实现屏障,然后所有线程到达屏障后,执行一起执行的方法
  • CyclicBarrier的初始化方法:当到达屏障的线程数达到parties时,执行Runnable中run方法的内存
public CyclicBarrier(int parties, Runnable barrierAction) {
    if (parties <= 0) throw new IllegalArgumentException();
    this.parties = parties;
    this.count = parties;
    this.barrierCommand = barrierAction;
}
  • 我的理解:在线程内部调用await方法,让它在await处停止,这个地方也叫做屏障,然后等所有的线程都到达屏障后,开始执行CyclicBarrier初始化的run方法中的内容,然后各自的线程再继续做自己的事情
  • await方法通过ReentrantLock锁实现
13.2.1 示例:
public static void main(String[] args) {
    /**
     * 集齐7颗龙珠召唤神龙
     */
    CyclicBarrier cyclicBarrier = new CyclicBarrier(7, () -> {
        System.out.println("召唤成功");
    });

    for (int i = 0; i < 7; i++) {
        // 注意lambda表达式要想获取外面局部变量的值,
        // 也就是匿名内部类要想获取要不变量的值,这个值必须是final的(jdk8以后可以不定义为final)
        // 并且不能修改
        final int temp = i;
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "收集" + temp + "个龙珠");
            try {
                cyclicBarrier.await(); //等待
                System.out.println("许愿");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (BrokenBarrierException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

13.3 CountDownLatch和CyclicBarrier的区别:

  • CountDownLatch是在多个线程都进行了latch.countDown()后才会触发事件,唤醒await()在latch上的线程,而执行countDown()的线程,执行完countDown()后会继续自己线程的工作;CyclicBarrier是一个栅栏,用于同步所有调用await()方法的线程,线程执行了await()方法之后并不会执行之后的代码,而只有当执行await()方法的线程数等于指定的parties之后,这些执行了await()方法的线程才会同时运行
  • CountDownLatch不能循环使用,计数器减为0就减为0了,不能被重置;CyclicBarrier提供了reset()方法,支持循环使用
  • CountDownLatch当调用countDown()方法的线程数等于指定的数量之后,可以唤起多条线程的任务;CyclicBarrier当执行await()方法的线程等于指定的数量之后,只能唤起一个BarrierAction

16.Semaphore

  • 相当于是一个并发控制器(限制并发数),是用于管理信号量的。
  • 构造的时候传入可供管理的信号量的数值,这个数值就是控制并发数量的,我们需要控制并发的代码,执行前先通过acquire方法获取信号,执行后通过release归还信号 。每次acquire返回成功后,Semaphore可用的信号量就会减少一个,如果没有可用的信号,acquire调用就会阻塞,等待有release调用释放信号后,acquire才会得到信号并返回。
  • 主要作用:限流,控制并发数
  • acquire():获得,假设已经满了,等待,等到被释放为止
  • release():释放,将当前的信号量释放,然后唤醒等待的线程
  • 我的理解:设置了最大并发5,此时有5个线程已经通过acquire方法拿到了执行权在执行了,又来了一个线程想通过acquire方法获得执行权,不能获得,只能等那5个线程的某个线程调用了release释放之后,它才能继续执行
  • 可以控制是公平锁还是非公平锁
  • acquire方法和release方法是可以有参数的,表示获取/返还的信号量个数
  • 通过AQS实现

16.1 semaphore的使用:

  • 使用Semaphore限流,在访问高峰期时,让请求线程阻塞,高峰期过去再释放许可,当然它只适合限制单机线程数量,并且仅是限制线程数,而不是限制资源数(例如连接数,请对比Tomcat LimitLatch的实现)
  • 用Semaphore实现简单连接池,对比【享元模式】下的实现(用wait notify),性能和可读性显然更好
  • 可以修改之前我们手写的tomcat的连接池的实现,注意tomcat的连接池的实现中线程数和数据库连接数是相等的(在这种情况更适合使用Semaphore)

16.2 示例:

public static void main(String[] args) {
    // 线程数量;停车位
    Semaphore semaphore = new Semaphore(3);
    // 6辆车
    for (int i = 0; i < 6; i++) {
        new Thread(() -> {
            try {
                // acquire()得到许可
                // 没有得到许可的线程就会在此等待,直到有线程归还许可
                semaphore.acquire();
                System.out.println(Thread.currentThread().getName() + "抢到车位");
                Thread.sleep(200);
                System.out.println(Thread.currentThread().getName() + "离开车位");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                // release()释放
                semaphore.release();
            }

        }).start();
    }
}

17.CopyOnWriteArrayList

并发安全的集合,适用于读操作较多的情况

常用集合在多线程下的问题:并发修改异常:

正常的集合中都有个参数modcount,用于记录操作次数,如果是并发修改,就能通过这个参数获知,是否发生并发修改异常。
解决办法:

  • 使用vector
  • 使用Collections.synchronizedList(new ArrayList<>())
  • 使用JUC的并发安全集合类

CopyOnWrite:写入时复制:

简单点说:就在把主内存上的内容,复制一份出来,对这个复制出来的内容进行修改,修改完了,再把这个复制出来的内容设置到主内存的需要修改的内容

CopyOnWriteArrayList的数组对象:

/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;

可以看到,它是被volatile修饰的,来保证这个数组对象的可见性

写操作:

public boolean add(E e) {
	// 加锁,保证修改的同步
    synchronized (lock) {
    	// 复制数组对象
        Object[] es = getArray();
        int len = es.length;
        es = Arrays.copyOf(es, len + 1);
        es[len] = e;
        // 写入
        setArray(es);
        return true;
    }
}

从add方法可以看到,我们的新增操作是加了synchronized锁的,具体我也不清楚在什么时候变成了synchronized锁,但是在jdk8的时候还是用的ReentrantLock锁。

接着将数组对象复制了一份副本,用于操作(增删改),操作完成之后,再将副本的数组写入到数组对象中,由于数组对象添加了volatile关键字,所以此修改会立即被其他线程发现,又因为添加了synchronized锁,所以修改的过程是不会被打扰的,保证了原子性

为什么要写入时复制?加了volatile直接修改不好么?

关于这个问题,就不得不提到 ConcurrentModificationException。ArrayList 在 for-each 循环中删除元素时,就会抛出 ConcurrentModificationException。其表面原因是该操作破坏了 ArrayList 内部维持的一系列状态,最后在检查中不通过,导致报错。而这样设计的本质原因,还是在于保障并发读写的安全性,因为迭代期间对底层数组进行了并发修改,这样很可能会导致不可预期的错误。ArrayList 为了防止这一问题,就会在迭代期间进行检测,如果发现了有并发读写现象,就会抛出这个 ConcurrentModificationException,这是一种 fail-fast(快速失败)机制。

再回到 CopyOnWriteArrayList 的问题。CopyOnWriteArrayList 的写操作进行了加锁。如果 CopyOnWriteArrayList 只有写操作,那么这里确实只通过加锁就可以保证安全,不需要进行复制。但是 CopyOnWriteArrayList 还有读操作,而且大多数情况下,List 都是读多写少的。所以这里本质上也依然是并发读写的问题:

  1. 若没有复制,写时加锁,读时不加锁,那么就会发生并发读写问题,产生不可预期的异常,即上面说的 ConcurrentModificationException;
  2. 若没有复制,写时加锁,读时也需要加锁,这样就相当于退化为 SynchronizedList,读性能大大减弱。

写时复制,则可以很好的处理并发读写问题,而且还保障了性能:

  1. 写时加锁,不会产生并发写的问题,保证了写操作的安全性;
  2. 实际的写操作,是在复制的新数组上进行;而同一时刻的读操作,是在原数组进行的,所以这里的读操作不会产生并发读写问题,也不需要加锁;
  3. 新数组操作完成后,将原数组替换,这里则是通过 volatile 关键字保障了新数组的线程可见性。

这样,引入写时复制的原因就说清楚了。实际上,这是 volatile、锁、写时复制三者共同作用的结果,既保证了并发读写的安全性,也保证了读的性能,三者缺一不可,可谓精妙。

一句话回答由于读没有加锁,而volatile并不保证原子性,写的过程中的数据修改会被其他线程看到

存在的不足:

  • 由于复制导致的内存占用问题
  • 由于复制导致的写操作的时间问题
  • 只能保证最终一致性,并不能保证实时一致性(读操作读到的是内存上的旧数据)

18.Future和FutureTask:

18.1 Future

Future是一个接口表示异步计算的结果,它提供了检查计算是否完成的方法,以等待计算的完成,并获取计算的结果。Future提供了get()、cancel()、isCancel()、isDone()四种方法,表示Future有三种功能:

  • 判断任务是否完成
  • 中断任务
  • 获取任务执行结果

18.2 FutureTask

FutureTask是Future的实现类,它提供了对Future的基本实现。可使用FutureTask包装Callable或Runnable对象,因为FutureTask实现了Runnable,所以也可以将FutureTask提交给Executor。

19.ReadWriteLock读写锁:

ReentrantReadWriteLock概述

大型网站中很重要的一块内容就是数据的读写,ReentrantLock虽然具有完全互斥排他的效果(即同一时间只有一个线程正在执行lock后面的任务),但是效率非常低,比如为了保证读到的数据是一致的,所以对数据加锁,但是在A线程读,B线程也是读的情况下,是不需要加锁的。所以在JDK中提供了一种读写锁ReentrantReadWriteLock,使用它可以加快运行效率。

读写锁表示两个锁,一个是读操作相关的锁,称为共享锁;另一个是写操作相关的锁,称为排他锁。我把这两个操作理解为三句话:

  1. 读和读之间不互斥,因为读操作不会有线程安全问题
  2. 写和写之间互斥,避免一个写操作影响另外一个写操作,引发线程安全问题
  3. 读和写之间互斥,避免读操作的时候写操作修改了内容,引发线程安全问题

总结起来就是,多个Thread可以同时进行读取操作,但是同一时刻只允许一个Thread进行写入操作

20.阻塞队列BlockingQueue

  • 多线程中,很多场景都可以使用队列实现,比如经典的生产者/消费者模型,通过队列可以便利地实现两者之间数据的共享,定义一个生产者线程,定义一个消费者线程,通过队列共享数据就可以了
  • 阻塞队列所谓的"阻塞",指的是某些情况下线程会挂起(即阻塞),一旦条件满足,被挂起的线程又会自动唤醒。使用BlockingQueue,我们可以不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,这些内容BlockingQueue都已经做好了
  • 比如当阻塞队列中已经满了,那么线程就会被挂起,直到队列中有数据被移除了,线程才会继续执行(有点类似于semphore信号量)
  • 相比于普通的Queue,有2对特有方法:
    • put(往队列插入)和take(从队列取出)
    • offer(,),设置超时等待的插入和poll(,),设置超时等待的取出
  • 主要实现类:
    • ArrayBlockingQueue:有界队列,基于数组(put和take使用的是同一把锁,意味着生产者和消费者同步执行)
    • LinkedBlockingQueue:无界队列,基于链表(生产者和消费者都有自己的锁,意味着put和take可以异步进行)
      • 当当前队列中的数据超过设置的count(通过atomicInteger定义)时,添加方法阻塞,直到小于count
      • 当当前队列中的数据小于等于0时,阻塞获取,直到队列中的数据不为空(count>0)
    • SynchronousQueue同步队列:队列中只能由一个数据,意味着一个线程的执行(移入队列),必须等待一个线程的结束(移出队列)
    • PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列。
    • DelayQueue:一个使用优先级队列实现的无界阻塞队列。
    • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
    • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

20.1 LinkedBlockingQueue的实现:

  • 内部维护了

21.线程池:ThreadPoolExecutor

21.1 优点:

线程复用,速度快(直接从池中取,不需要创建线程),可以管理线程,可以控制最大并发数

21.2 ThreadPoolExecutor七个核心参数

 public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {
1、corePoolSize:核心池的大小

在创建了线程池之后,默认情况下,线程池中没有任何线程,而是等待有任务到来才创建线程去执行任务。默认情况下,在创建了线程池之后,线程池中的线程数为0,当有任务到来后就会创建一个线程去执行任务

2、maximumPoolSize:池中允许的最大线程数

这个参数表示了线程池中最多能创建的线程数量,当任务数量比corePoolSize大时,任务添加到workQueue,当workQueue满了,将继续创建线程以处理任务,maximumPoolSize表示的就是wordQueue满了,线程池中最多可以创建的线程数量。
救急线程是有生命时间的,而核心线程没有,不会被销毁

3、keepAliveTime:救急线程被释放等待时间

只有当线程池中的线程数大于corePoolSize时,这个参数才会起作用。当线程数大于corePoolSize时,如果有线程等待任务时间超过此最长时间,将被释放。

4、unit:keepAliveTime时间单位
5、workQueue:阻塞队列

存储还没来得及执行的任务(候客区)

6、threadFactory:线程工厂

执行程序创建新线程时使用的工厂

7、handler:拒绝策略

由于超出线程范围和队列容量而使执行被阻塞时所使用的处理程序。

意思就是请求队列超过了线程池的最大容量,也超过了等待队列的容量而采用的策略。

在这里插入图片描述

21.1 阻塞队列的作用:

既阻塞任务的到达,也阻塞无任务时的处理线程

  • 无任务时,使线程进入wait状态,释放cpu资源,保证核心线程存活并且不占用cpu资源,
  • 等有任务到达时,唤醒wait状态的线程执行任务

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

因为在创建新线程的时候,是要获取全局锁的,这个时候其它的就得阻塞,影响了整体效率,而使用阻塞队列就可以很好的缓冲,避免创建最大线程

21.3 线程池工作方式详解:

  1. 刚开始,线程池中并没有线程,当一个任务提交给线程池后,线程池会创建一个新线程来执行任务
  2. 当线程数达到 corePoolSize时,并且没有线程空闲,这时再加入任务,新加的任务会被加入workQueue 队列排队,直到有空闲的线程。
  3. 如果队列选择了有界队列,那么任务超过了队列大小时,会创建最多maximumPoolSize - corePoolSize 数目的线程来救急。
  4. 如果线程到达 maximumPoolSize 仍然有新任务这时会执行拒绝策略。拒绝策略 jdk 提供了 下面的前4 种实现
    1. ThreadPoolExecutor.AbortPolicy让调用者抛出 RejectedExecutionException 异常,这是默认策略
    2. ThreadPoolExecutor.CallerRunsPolicy 让调用者运行任务(我们公司使用的策略)
    3. ThreadPoolExecutor.DiscardPolicy 放弃本次任务
    4. ThreadPoolExecutor.DiscardOldestPolicy 放弃队列中最早的任务,本任务取而代之
  5. 其它著名框架也提供了实现
    1. Dubbo 的实现,在抛出 RejectedExecutionException 异常之前会记录日志,并 dump 线程栈信息,方便定位问题
    2. Netty 的实现,是创建一个新线程来执行任务
    3. ActiveMQ 的实现,带超时等待(60s)尝试放入队列,类似我们之前自定义的拒绝策略
    4. PinPoint 的实现,它使用了一个拒绝策略链,会逐一尝试策略链中每种拒绝策略
  6. 当高峰过去后,超过corePoolSize 的救急线程如果一段时间没有任务做,需要结束节省资源,这个时间由 keepAliveTime 和 unit 来控制。

21.4 如何确定最大线程数?

  • CPU密集型:数据分析等占用CPU的
    • max = cpu线程数+1 (一般选这种)
  • IO密集型:Web应用程序等
    • 线程数 = 核数 x 期望 CPU 利用率 x 总时间百分比(即CPU计算时间+等待时间) / cpu计算时间百分比

21.5 关于任务队列的选择:有界队列和无界队列

JDK使用了无界队列LinkedBlockingQueue作为WorkQueue,而不是有界队列ArrayBlockingQueue,尽管后者可以对资源进行控制。

21.5.1 有界队列缺点:

主要考虑任务过多可能会丢弃的情况。

  1. 使用有界队列,corePoolSize、maximumPoolSize两个参数势必要根据实际场景不断调整以求达到一个最佳,这势必给开发带来极大的麻烦,必须经过大量的性能测试。所以干脆就使用无界队列,任务永远添加到队列中,不会溢出,自然maximumPoolSize也没什么用了,只需要根据系统处理能力调整corePoolSize就可以了
  2. 防止业务突刺。尤其是在Web应用中,某些时候突然大量请求的到来都是很正常的。这时候使用无界队列,不管早晚,至少保证所有任务都能被处理到。但是使用有界队列呢?那些超出maximumPoolSize的任务直接被丢掉了,处理地慢还可以忍受,但是任务直接就不处理了,这似乎有些糟糕
  3. 不仅仅是corePoolSize和maximumPoolSize需要相互调整,有界队列的队列大小和maximumPoolSize也需要相互折衷,这也是一块比较难以控制和调整的方面
21.5.2 无界队列缺点:

主要考虑cpu分片和oom的情况。

  • 线程的生命周期开销非常高
  • 消耗过多的 CPU
  • 资源如果可运行的线程数量多于可用处理器的数量,那么有线程将会被闲置。大量空闲的线程会占用许多内存,给垃圾回收器带来压力,而且大量的线程在竞争 CPU资源时还将产生其他性能的开销。
  • 降低稳定性JVM
  • 在可创建线程的数量上存在一个限制,这个限制值将随着平台的不同而不同,并且承受着多个因素制约,包括 JVM 的启动参数、Thread 构造函数中请求栈的大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,那么可能抛出OutOfMemoryError 异常。

21.6 Executors创建的几个线程池:(不推荐)

  1. newSingleThreadExecutos() 单线程线程池
  2. newFixedThreadPool(int nThreads) 固定大小线程池
  3. newCachedThreadPool() 无界线程池

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

  • 接收参数:execute()只能执行 Runnable 类型的任务。submit()可以执行 Runnable 和 Callable 类型的任务。
  • 返回值:submit()方法可以返回持有计算结果的 Future 对象,而execute()没有
  • 异常处理:submit()方便Exception处理

23.死锁:

线程A持有锁A想要锁B,线程B持有锁B想要锁A。

形成死锁的的四个必要条件:

  1. 互斥条件:线程(进程)对于所分配到的资源具有排它性,即一个资源只能被一个线程(进程)占用,直到被该线程(进程)释放
  2. 请求与保持条件:一个线程(进程)因请求被占用资源而发生阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程(进程)已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  4. 循环等待条件:当发生死锁时,所等待的线程(进程)必定会形成一个环路(类似于死循环),造成永久阻塞

如何定位死锁

检测死锁可以使用 jconsole工具;或者使用 jps 定位进程 id,再用 jstack 定位死锁

如何防止死锁:

防止死锁可以采用以下的方法:

  • 尽量使用 tryLock(long timeout, TimeUnit unit)的方法(ReentrantLock、ReentrantReadWriteLock),设置超时时间,超时可以退出防止死锁。
  • 尽量使用 Java. util. concurrent 并发类代替自己手写锁。
  • 尽量降低锁的使用粒度,尽量不要几个功能用同一把锁。
  • 尽量减少同步的代码块。

死锁与活锁的区别,死锁与饥饿的区别?

  • 死锁:是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。
  • 活锁:任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败。

活锁和死锁的区别在于,处于活锁的实体是在不断的改变状态,这就是所谓的“活”, 而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。

饥饿:一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。

Java 中导致饥饿的原因:

  • 高优先级线程吞噬所有的低优先级线程的 CPU 时间。
  • 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。
  • 线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的 wait 方法),因为其他线程总是被持续地获得唤醒。

atomic的一些原子类:

查看原文稍微看看:atomic相关类

springmvc的Controller不是线程安全的

其他问题

线程类的构造方法、静态块是被哪个线程调用的

请记住:线程类的构造方法、静态块是被 new这个线程类所在的线程所调用的,而 run 方法里面的代码才是被线程自身所调用的。

如果说上面的说法让你感到困惑,那么我举个例子,假设 Thread2 中 new 了Thread1,main 函数中 new 了 Thread2,那么:

  • Thread2 的构造方法、静态块是 main 线程调用的,Thread2 的 run()方法是Thread2 自己调用的
  • Thread1 的构造方法、静态块是 Thread2 调用的,Thread1 的 run()方法是Thread1 自己调用的

as-if-serial规则和happens-before规则的区别

  • as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
  • as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
  • as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

线程池/异步运行出现异常如何处理?

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

面试知识点梳理及相关面试题(五)-- 多线程 的相关文章

随机推荐

  • 微弱直流电压/电流信号的采样电路 --滤波跟随放大

    要求将待测的电压 1mV 1000mV 电流 1mA 100mA 采样出来传给单片机 我的思路是 电压采样先用放大电路放大 再进行滤波 把50Hz的交流电干扰滤除 然后再进行模数转换传给单片机 电流的话用一个采样电阻 然后对其电压采样后推算
  • 信息学奥赛一本通【1034:计算三角形面积】

    题目描述 平面上有一个三角形 它的三个顶点坐标分别为 x1 y1 x2 y2 x3 y3 x1 y1 x2 y2 x3 y3 那么请问这个三角形的面积是多少 精确到小数点后两位 输入 输入仅一行 包括66个单精度浮点数 分别对应x1 y1
  • 关于git的一些命令,实验记录

    1 下载moduleA项目代码 命令 git clone https github com wangyanan52121 moduleA git 因网络问题 可能会下载失败 多试几次 2 查看当前项目 查看tag命令 字符串排序 git C
  • 语言深入理解指针(非常详细)(三)

    目录 数组名的理解 使用指针访问数组 一维数组传参的本质 二级指针 指针数组 指针数组模拟二维数组 数组名的理解 在上 个章节我们在使用指针访问数组的内容时 有这样的代码 int arr 10 1 2 3 4 5 6 7 8 9 10 in
  • mysql如何一秒插入10万条数据

    当我们需要批量插入或者更新记录时 可以采用Java的批量更新机制 该机制允许多条语句甚至一次性提交给数据库处理 通常情况下比一句一提交处理更有效率 jdbc处理批量提交有三个方法 需要注意的是 这三种方法都要和PreparedStateme
  • Rx与Async Task的简单对比

    有关Reactive Extensions的介绍可见https rx codeplex com 总的来说 你可以当它是又一个异步编程的框架 它以观察者模式实现了对数据流的的 订阅 一个列表 一个事件 一个耗时操作的方法 等等 都可以Obse
  • C++ 多线程std::async

    std async 对于线程的创建 我们可以直接用thread 但是这会有很多的不便 比如获取子进程的返回值 解决方案是定义一个变量 然后将变量的指针传入到子进程中 然后对其进行赋值 但终归是不便 除此之外我们可以用std async函数来
  • 如何正确理解开漏输出和推挽输出

    作者 知乎用户 链接 https www zhihu com question 28512432 answer 41217074 来源 知乎 著作权归作者所有 商业转载请联系作者获得授权 非商业转载请注明出处 我觉得下面这个 网上资料 还是
  • java项目如何实现数据恢复_Java web 项目中对数据库备份和恢复

    说白了 还是去调用cmd实现数据库的备份和还原功能 备份 mysqldump hserverUrl uusername ppassword dbname gt savePath 还原 mysql hserverUrl uusername p
  • 一文读懂SpringCloud全家桶

    一 云原生应用 SpringCloud是对Springboot使用的分布式解决方案 适合分布式 中大型的项目架构开发 现在也逐渐成为Java服务端的主流框架 使用Spring Cloud开发的应用程序非常适合在Docker和PaaS 比如P
  • C# 提交报错:Validation of viewstate MAC failed 解决办法

    出现以下报错 验证视图状态 MAC 失败 如果此应用程序由网络场或群集承载 请确保
  • 【STM32】SPI初步使用 读写FLASH W25Q64

    硬件连接 1 SS Slave Select 从设备选择信号线 常称为片选信号线 每个从设备都有独立的这一条 NSS 信号线 当主机要选择从设备时 把该从设备的 NSS 信号线设置为低电平 该从设备即被选中 即片选有效 接着主机开始与被选中
  • MySQL 8.0 多实例的配置应用

    文章目录 同版本多实例 配置 部署 启动 连接 不同版本多实例 配置 初始化 initialize insecure 含义 启动 同版本多实例 配置 mkdir p data 330 7 9 data chown R mysql mysql
  • 同城双活与异地多活架构分析

    本文首发于 vivo互联网技术 微信公众号 链接 https mp weixin qq com s OjfFcjnGWV5kutxXndtpMg 作者 vivo官网商城开发团队 采用高可用系统架构支持重要系统 为关键业务提供7x24的不间断
  • Win32 UDP Socket通信学习

    学习内容 参见 Windows网络编程 第7章 Winsock基础 与TCP流式协议不同 UDP为数据报协议 服务端接受数据 客户端发送数据 UDP服务端流程 Socket或WSASocket建立套接字 用SOCK DGRAM标志 bind
  • bootstrap--栅格系统详解(源码分析)

    目录 1 bootstrap是什么 2 栅格模型设计的精妙之处 3 预备知识 4 栅格系统的框架 4 1bootstrap容器 4 1 1固定容器与流体容器公共样式 4 1 2固定容器样式 4 2 bootstrap行与列 4 2 1行 4
  • js中的navigator对象 用js判断浏览器类型

    navigator对象 window navigator返回一个navigator对象的引用 可以用它来查询一些关于运行当前脚本的应用程序的相关信息 在浏览器打印一下navigator对象 console log navigator MDN
  • 基于Nonconvex规划的配电网重构研究(Matlab代码实现)

    欢迎来到本博客 博主优势 博客内容尽量做到思维缜密 逻辑清晰 为了方便读者 座右铭 行百里者 半于九十 本文目录如下 目录 1 概述 2 运行结果 3 参考文献 4 Matlab代码实现 1 概述 本文基于Nonconvex规划的配电网重构
  • FastAPI从入门到实战(8)——一文弄懂Cookie、Session、Token与JWT

    看到标题应该也能看出来本文讲的就是前端鉴权相关的内容了 鉴权也就是身份认证 指验证用户是否有系统的访问权限 只要是web开发 这部分内容就是不可能不学的 很多面试也必问 所以本文就针对此主题详细记录一下其常见的几种方式 HTTP状态 HTT
  • 面试知识点梳理及相关面试题(五)-- 多线程

    1 进程 线程和协程 进程 一个运行的程序就是一个进程 进程是资源分配的最小单位 线程 程序中运行的一个个子任务就是一个线程 线程是操作系统调度执行的最小单位 协程 协程是一种用户态的轻量级线程 协程的调度完全由用户控制 2 创建线程的四种