并发基础

1、线程、进程与协程

线程、进程与协程的区别
  • 进程:本质上是⼀个独立执行的程序,操作系统进行资源分配和调度的⼀个独立单位。
  • 线程:操作系统进行运算调度的最小单位。属于进程,是进程中实际工作的单位。一个进程中可以并发多个线程,每条线程执行不同的任务,线程的切换受系统控制。
  • 协程:又称微线程,是一种用户态的轻量级线程。不像线程和进程需要进行系统内核上的上下文切换,协程的上下文切换是由用户自己决定的,有自己的上下文,所以说是轻量级的线程。
    一个线程可以有多个协程,线程与进程都是同步机制,而协程则是异步。Java的原生语法中并没有实现协程,目前python、Lua和GO等语言⽀持。

一个进程可以有多个线程,其允许计算机同时运行多个程序。线程是进程的最小执行单位,CPU的调度切换的是进程的线程,进程和线程数量多了后调度会消耗大量CPU资源。
CPU上运行的是线程,线程又可以对应多个协程。

协程对于多线程的优缺点

优点:

  • 快速高效的上下文切换,无需对系统内核上下文进行切换,减少开销。
  • 单线程即可实现高并发,单核CPU就可以支持上万的协程。
  • 因为只有一个线程,就不存在同时写变量的冲突,在协程中控制共享资源不需要加锁。

缺点:

  • 无法利用CPU多核资源,本质上还是单线程。
  • 需要和进程配合才能运行在多CPU上。
  • Java目前无成熟的第三方库,存在风险。
  • 调试难度高,不利于排查与发现问题。

2、并发与并行

  • 并发指在⼀段时间内宏观上去处理多个任务。
  • 并行指同⼀个时刻,多个任务确实真的同时运行。

并发 concurrency:

  • ⼀台处理器上同时处理任务, 这个同时实际上是交替处理多个任务,程序中可以同时拥有两个或者多个线程,当有多个线程在操作时,如果系统只有⼀个CPU,则它根本不可能真正同时进行⼀个以上的线程,它只能把CPU运行时间划分成若干个时间段,再将时间段分配给各个线程执行。

并行 parallellism:

  • 多个CPU上同时处理多个任务,⼀个CPU执行⼀个进程时,另⼀个CPU可以执行另⼀个进程,两个进程互不抢占CPU资源,可以同时进行。

3、Java中的多线程实现方式

常用 Runnable 接口 和 线程池 + Runnable 方式,方便拓展与高性能(池化思想)

1)继承Thread
  • 继承 Thread 类并重写 run 方法,创建实例后执行 start 开始任务。
  • 优点:最简单直接的操作。
  • 缺点:无返回值且无法再继承其它类,扩展性较差。
public class ThreadDemo1 extends Thread {
 @Override
 public void run() {
 System.out.println("继承Thread实现多线程,名称:"+Thread.currentThread().getName());
 }
}
2) 实现 Runnable 接口
  • 自定义类实现 Runnable 接口,实现 run 方法,创建 Thread 类,将 自定义类作为参数传递给 Thread 对象,然后调用 start 开始任务。
  • 优点:可以实现多个接口并再继承一个类。
  • 缺点:无返回值,不能直接启动,需要构建 Thread 实例后传递才能使用。
public class ThreadDemo2 implements Runnable {
 @Override
 public void run() {
 System.out.println("通过Runnable实现多线程,名称:"+Thread.currentThread().getName());
 }
}

public static void main(String[] args) {
 ThreadDemo2 threadDemo2 = new ThreadDemo2();
 Thread thread = new Thread(threadDemo2);
 thread.setName("demo2");
 thread.start();
 System.out.println("主线程名称:"+Thread.currentThread().getName());
}

//JDK8之后可采⽤lambda表达式
public static void main(String[] args) {
 Thread thread = new Thread(()->{
 System.out.println("通过Runnable实现多线程,名称:"+Thread.currentThread().getName());
 });
 thread.setName("demo2");
 thread.start();
 System.out.println("主线程名称:"+Thread.currentThread().getName());
}
3)通过Callable和FutureTask⽅式
  • 创建callable接口的实现类,并实现call方法,结合FutureTask类包装Callable对象,实
    现多线程。
  • 优点:有返回值,拓展性高。
  • 缺点:jdk5以后才支持,需要重写call⽅法,结合多个类⽐如FutureTask和Thread类
public class MyTask implements Callable<Object> {
 @Override
 public Object call() throws Exception {
 System.out.println("通过Callable实现多线程,名称:"+Thread.currentThread().getName());
 return "这是返回值";
 }
}

 public static void main(String[] args) {
 FutureTask<Object> futureTask = new FutureTask<>(()->{
 System.out.println("通过Callable实现多线程,名称:"+Thread.currentThread().getName());
 return "这是返回值";
 });
// MyTask myTask = new MyTask();
// FutureTask<Object> futureTask = new FutureTask<>(myTask);
 //FutureTask继承了Runnable,可以放在Thread中启动执⾏
 Thread thread = new Thread(futureTask);
 thread.setName("demo3");
 thread.start();
 System.out.println("主线程名称:"+Thread.currentThread().getName()); try {
 System.out.println(futureTask.get());
 } catch (InterruptedException e) {
 //阻塞等待中被中断,则抛出
 e.printStackTrace();
 } catch (ExecutionException e) {
 //执⾏过程发送异常被抛出
 e.printStackTrace();
 }
 }
通过线程池创建线程
  • 自定义Runnable接口,实现run⽅法,创建线程池,调用执行方法并传入对象
  • 优点:安全高性能,线程能复用。
  • 缺点: jdk5后才⽀持,需要结合Runnable进⾏使⽤
public class ThreadDemo4 implements Runnable {
 @Override
 public void run() {
 System.out.println("通过线程池+runnable实现多线程,名称:"+Thread.currentThread().getName());
 }
}

public static void main(String[] args) {
 ExecutorService executorService =Executors.newFixedThreadPool(3);
 for(int i=0;i<10;i++){
 executorService.execute(new ThreadDemo4());
 }
 System.out.println("主线程名称:"+Thread.currentThread().getName());
 //关闭线程池
 executorService.shutdown();
 }

5、线程的状态

常见的 5 种状态:

  • 创建(NEW): 创建线程对象,但是并没有调用该对象start()。
  • 就绪(Runnable):当调用线程对象的start()⽅法,线程就进⼊就绪状态,但是此刻线程调度还没把该线程设置为当前线程,没获得CPU使用权。
    如果线程运行后,从等待或者睡眠中回来,也会进⼊就绪状态。
  • 运行(Running):程序将处于就绪状态的线程设置为当前线程,即获得CPU使用权,这个时候线程进⼊运行状态,开始运行run方法内的逻辑。
  • 阻塞(Blocked)
    等待阻塞:进入该状态的线程需要等待其他线程作出⼀定动作(通知或中断),这种状态下CPU不会分配过来,需要被唤醒。可能也会无限期等待下去。如调用wait(状态就会变成WAITING状态),也可能通过调用sleep(状态就会变成TIMED_WAITING), join或者发出IO请求,阻塞结束后线程重新进入就绪状态。
    同步阻塞:线程在获取synchronized同步锁失败,即锁被其他线程占用,它就会进入同步阻塞状态。
  • 死亡(TERMINATED):⼀个线程run方法执行结束,该线程就死亡了,不能进⼊就绪状态。

6、Java线程的常见方法

属于线程Thread的方法:

  1. sleep:
    暂缓线程执行,等待预计时间之后再恢复。
    交出CPU使用权,但不会让出锁
    进⼊阻塞状态TIME_WAITGING,睡眠结束变为就绪Runnable。
  2. yield:
    暂停当前线程对象,去执行其它线程。
    交出CPU使用权,但不会释放锁
    作用:让相同优先级的线程轮流执行,但不保证一定轮流。
    注意:线程不会进入阻塞状态,而是直接变为就绪状态
  3. join:
    作用:让调用 join 方法的线程先执行,再去执行其它线程。
    再主线程上调用该等待会让主线程休眠,但不会释放已经持有的锁

属于 Object 的方法:

  1. wait:
    调用该方法的线程会释放锁并进入线程的等待队列。
    需要依靠 notify或notifyAll唤醒,或者 wait(timeout)时间自动唤醒。
  2. notify:唤醒在对象监视器上等待的单个线程,选择是任意的。
  3. notifyAll: 唤醒在对象监视器上等待的全部线程。

7、Java线程状态转换图

java 多并发面试题 java并发编程面试_java

8、volatile 关键字

什么是 volatile?与 synchronized 的区别?
  • volatile 是轻量级的 synchronized,保证了变量的可见性,被其修饰的变量若值发生了变化,其它线程能立即可见,可避免出现脏读现象。
  • volatile:保证可见性,但不保证原子性。
    synchronized:保证可见性,也保证原子性。
volatile 的使用场景
  • 不能修饰写⼊操作依赖当前值的变量,如num++、num=num+1,不是原子操作,肉眼看起来是,但是JVM字节码层面不是。
  • 由于禁止了指令重排,所以JVM相关的优化没了,效率会偏弱。
volatile如何避免出现脏读?
  • JMM(JAVA内存模型)规定所有的变量存在在主内存,每个线程有独立的工作内存,线程对变量的操作都在工作内存中进行,不能直接对主内存进行操作。
  • 使用volatile修饰变量,每次读取前必须从主内存属性最新的值,每次写⼊需要立刻写到主内存中。
  • volatile关键字修修饰的变量随时看到的自己的最新值,假如线程1对变量进行修改,那么线程2可马上看到。
volatile 可以避免指令重排,那什么是指令重排?
  • 指令重排序分两类,编译器重排序和运行时重排序。
  • JVM在编译java代码或者CPU执行JVM字节码时,对现有的指令进行重新排序,主要目的是优化运行效率(以不改变程序结果的前提)。
虽然指令重排序可以提高执行效率,但是多线程上可能会影响结果,有什么解决办法?
  • 可用 内存屏障 来解决。
  • 内存屏障是屏障指令,使CPU对屏障指令之前和之后的内存操作执行结果的⼀种约束。
什么是 happens-before?

先⾏发发生原则,volatile的内存可见性就提现了该原则之⼀。

八大原则:
1、程序次序规则
2、管程锁定规则
3、volatile变量规则
4、线程启动规则
5、线程中断规则
6、线程终⽌规则
7、对象终结规则
8、传递性

8、常见多线程使用考察

多线程使用场景
  • 异步业务:用户注册、记录日志。
  • 定时任务:定期备份任务、备份数据库。
  • 分布式计算:Hadoop处理任务mapreduce,master-wark(单机单进程)。
  • 服务器编程:Socket⽹络编程,⼀个连接⼀个线程。
非线程安全的数据结构
  • HashMap、ArrayList、LinkedLis等。
在Java中可以有哪些方法来保证线程安全
  • 加锁,比如synchronize/ReentrantLock
  • 使用volatile声明变量,轻量级同步,不能保证原⼦性。
  • 使用线程安全类(原⼦类AtomicXXX,并发容器,同步容器CopyOnWriteArrayList/ConcurrentHashMap等。
  • ThreadLocal本地私有变量/信号量Semaphore等

并发进阶

1、并发编程三要素

原子性

  • 指一个或多个操作要么全部执行成功要么全部失败,期间不能被中断,也不能存在上下文的切换,线程切换会带来原子性问题。
  • 对于非原子性操作转变为原子性操作:
    1、使用原子类,如 即java.util.concurrent.atomic⾥的原⼦变量类。
    2、对非原子类使用 synchronized 或 Lock(如ReentrantLock)来保证原子性(注意volatile不能修饰有依赖值的情况)。

有序性:程序执行的顺序按照代码的先后顺序执行,因为处理器可能会对指令进行重排序。
JVM在编译java代码或者CPU执⾏JVM字节码时,对现有的指令进行重新排序,主要目的是优化运行效率(不改变程序结果的前提)。

可见性: ⼀个线程A对共享变量的修改,另⼀个线程B能够立刻看到。

2、进程与线程的调度算法

进程间的调度算法
  • 先来先服务调度算法:按照作业/进程到达的先后顺序进行调度 。排在长进程后的短进程的等待时间长,不利于短作业/进程。
  • 短作业优先调度算法:短进程/作业(要求服务时间最短)在实际情况中占有很大比例,可以使得它们优先执行。对长作业不友好。
  • 高响应比优先调度算法:在每次调度时,先计算各个作业的优先权,,选择优先权高的进行服务。需要计算优先权信息,增加了系统的开销。

优先权 = 响应比 = (等待时间+预计服务时间)/(预计服务时间)

  • 时间片轮转调度算法:轮流的为各个进程服务,让每个进程在⼀定时间间隔内都可以得到响应。由于⾼频率的进程切换,会增加了开销,且不区分任务的紧急程度。
  • 优先级调度算法:根据任务的紧急程度进行调度,高优先级的先处理,低优先级的慢处理。可能存在低优先级进程长时间等待高优先级进程处理完的情况。
线程间的调度算法

线程调度是指系统为线程分配CPU使用权的过程,主要分两种:

  • 协同式线程调度(分时调度模式):线程执行时间由线程本身来控制,工作执行完之后,要主动通知系统切换到另外⼀个线程上。
    实现简单,切换操作对线程是可知的,无线程同步问题。
    但线程执行时间不可控制的,如果⼀个线程有问题,可能⼀直被阻塞。
  • 抢占式线程调度:每个线程将由系统来分配执⾏时间,线程的切换不由线程本身来决定(Java中,Thread.yield()可以让出执行时间,但无法获取执行时间)。线程执行时间系统可控,也不会有⼀个线程导致整个进程阻塞的问题。

Java线程调度就是抢占式调度,优先让可运行池中优先级高的线程占衃CPU,如果池中的线程优先级相同,那就随机选择⼀个线程。
可通过设置线程优先级外控制线程的分配时间,Java的线程优先级由1到10的整数指定。多线程运行时,VM⼀般会运行最高优先级的线程(Thread.MIN_PRIORITYThread.MAX_PRIORITY)。
在两线程同时处于就绪runnable状态时,优先级越高的线程越容易被系统选择执行。但是优先级并不是100%可以获得,只不过是机会更大而已。

3、Java 中的锁分类

  • 悲观锁:当线程去操作数据的时候,总认为别的线程会去修改数据,所以它每次拿数据的时候都会上锁,别的线程去拿数据的时候就会阻塞,如synchronized。
  • 乐观锁:乐观锁认为在数据更新期间没有其他线程影响。更新的时候再判断是别人是否更新数据。通过版本来判断,如果数据被修改了就拒绝更新,如CAS是乐观锁,但严格来说并不是锁,通过原子性来保证数据的同步,再比如说数据库的乐观锁,通过版本控制来实现,CAS不会保证线程同步。
  • 公平锁:指线程按照申请锁的顺序来获取锁,以保证每个线程最后都能拿到锁。ReentrantLock 支持公平锁(底层是同步队列FIFO:First Input First Output来实现)。
  • 非公平锁:锁是被线线程随机获取的,不保证每个线程都能获得锁,可能存在线程饿死的现象,synchronized是非公平锁,ReentrantLock 也支持。
    一般来说非公平锁性能高于公平锁,更能重复利⽤CPU的时间。
  • 不可重入锁:若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会被阻塞。
  • 可重入锁:也叫递归锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁。
    可重⼊锁能⼀定程度的避免死锁,synchronized、ReentrantLock 是可重入锁。
  • 自旋锁:线程在获取锁时,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环,任何时刻最多只能有⼀个执行单元获得锁。
    不会发发线程状态的切换,⼀直处于用户态,减少了线程上下文切换的消耗,缺点是循环会消耗CPU资源。
    常见自旋锁:TicketLock,CLHLock,MSCLock。
  • 共享锁:也叫S锁/读锁,能查看但无法修改和删除的⼀种数据锁,加锁后其它用户可以并发读取、查询数据,但不能修改,增加,删除数据,该锁可被多个线程所持有,用于资源数据共享。
  • 互斥锁:也叫X锁/排它锁/写锁/独占锁/独享锁/ 该锁每⼀次只能被⼀个线程所持有,加锁后任何线程试图再次加锁的线程会被阻塞,直到当前线程解锁。
  • 死锁 :两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的⼀种阻塞的现象,若无外力作用,它们都将无法让程序进行下去。

Java 6 之后,Jvm为了提高锁的获取与释放效率针对Synchronized的锁进行了升级,锁的状态是通过对象监视器在对象头中的字段来表明,是不可逆的过程:

  • 偏向锁:⼀段同步代码⼀直被⼀个线程所访问,那么该线程会自动获取锁,获取锁的代价低。
  • 轻量级锁:当锁是偏向锁的时候,被其他线程访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,但不会阻塞。
  • 重量级锁::当锁为轻量级锁的时候,其他线程虽然是自旋,但自旋不会⼀直循环下去,自旋⼀定次数的时候且还没有获取到锁,就会进⼊阻塞,该锁升级为重量级锁,重量级锁会让其他申请的线程进入阻塞,性能也会降低。

4、死锁

发生死锁的四个必要条件:
  • 互斥条件:资源不共享。
  • 请求与保持条件:在已经获取到资源的情况下去请求其它资源发生阻塞,但对已经获得的资源保持不释放。
  • 不可抢占:资源不能被强制释放,只能由线程完成后主动释放。
  • 循环等待条件:多个线程形成环形链,每个都占用对方申请的下个资源。
死锁预防与死锁避免

TODO

5、synchronized

特性:

  • synchronized是解决线程安全的问题,常用在同步普通方法、静态方法、代码块中。
  • 非公平锁、可重入锁。
  • 每个对象有⼀个锁和⼀个等待队列,锁只能被⼀个线程持有。其他需要锁的线程需要阻塞等待。锁被释放后,对象会从队列中取出⼀个并唤醒,唤醒哪个线程是不确定的,不保证公平性。

两种形式:

  • 方法:生成的字节码文件中会多⼀个 ACC_SYNCHRONIZED 标志位,当⼀个线程访问方法时,会去检查是否存在ACC_SYNCHRONIZED标识,如果存在,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同⼀个monitor对象,也叫隐式同步
  • 代码块:加了 synchronized 关键字的代码段,⽣成的字节码⽂件会多出 monitorenter 和monitorexit 两条指令,每个monitor维护着⼀个记录着拥有次数的计数器, 未被拥有的monitor的该计数器为0,当⼀个线程获执行monitorenter后,该计数器自增1;当同⼀个线程执行monitorexit指令的时候,计数器再自减1。当计数器为0的时候,monitor将被释放。也叫显式同步

两种本质上没有区别,底层都是通过monitor来实现同步, 只是方法的同步是⼀种隐式的方式来实现,无需通过字节码来完成。

6、关于 CAS (Compare and Swap)

什么是CAS?
  • 全称是Compare And Swap,即比较再交换,是实现并发应用到的⼀种技术。
  • 底层通过Unsafe类实现原子性操作操作包含三个操作数 —— 内存地址(V)、预期原值(A)和新值(B)
  • 如果内存地址的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 ,若果在第⼀轮循环中,a线程获取地址内的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。

CAS是属于乐观锁,性能较悲观锁有较大的提高。
AtomicXXX 等原子类底层就是CAS实现,⼀定程度比synchonized好,因为后者是悲观锁。

CAS存在的问题
  • 自旋时间长CPU利用率增加,CAS内是⼀个循环判断的过程,如果线程⼀直没有获取到状态,cpu资源会⼀直被占用。
  • 存在ABA问题
CAS 的 ABA 问题
  • 如果⼀个变量V初次读取是A值,并且在准备赋值的时候也是A值,那就能说明A值没有被修改过吗?其实是不能的,因为变量V可能被其他线程改回A值,结果就是会导致CAS操作误认为从来没被修改过,从而赋值给V。
  • 给变量加⼀个版本号即可,在比较的时候不仅要⽐较当前变量的值 还需要比较当前变量的版本号。
    在java5中,已经提供了AtomicStampedReference来解决问题,检查当前引用是否等于预期引用,其次检查当前标志是否等于预期标志,如果都相等就会以原子的方式将引用和标志都设置为新值。

并发底层

1、并发编程核心底层AQS

什么是AQS?

AQS 全称AbstractQueuedSynchronizer,这个类在java.util.concurrent.locks
包下。是⼀个Java提供的底层同步⼯具类,如CountDownLatch、ReentrantLock,Semaphore,ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。

简单描述:是⼀个int类型的变量表示同步状态,并提供了⼀系列的CAS操作来管理这个同步状态对象的框架:

  • state:用于计数器,类似gc的回收计数器。
  • 线程标记:标记当前线程是谁加锁的。
  • 阻塞队列:用于存放其他未拿到锁的线程。

例子:线程A调用了lock()方法,通过CAS将state赋值为1,然后将该锁标记为线程A加锁。如果线程A还未释放锁时,线程B来请求,会查询锁标记的状态,因为当前的锁标记为 线程A,线程B未能匹配上,所以线程B会加⼊阻塞队列,直到线程A触发了 unlock() ⽅法,这时线程B才有机会去拿到锁,但是不⼀定肯定拿到。

源码:

  • acquire(int arg): 类似加锁lock操作。
  • tryAcquire():尝试直接去获取资源,如果成功则直接返回,AQS内未实现但没有定义成abstract,因为独占模式下只用实现tryAcquire-tryRelease,而共享模式下只用实现tryAcquireShared-tryReleaseShared,类似设计模式⾥⾯的适配器模式。
  • addWaiter():根据不同模式将线程加入等待队列的尾部,有Node.EXCLUSIVE互斥模式、Node.SHARED共享模式;如果队列不为空,则以通过compareAndSetTail方法以CAS将当前线程节点加⼊到等待队列的末尾。否则通过enq(node)方法初始化⼀个等待队列。
  • acquireQueued():使线程在等待队列中获取资源,⼀直获取到资源后才返回,如果在等待过程中被中断,则返回true,否则返回false。
  • release(int arg):类似解锁 unlock 操作。
    独占模式下线程释放指定量的资源,⾥⾯是根据tryRelease()的返回值来判断该线程是
    否已经完成释放掉资源了;在⾃义定同步器在实现时,如果已经彻底释放资源(state=0),要返回true,否则返回false
  • unparkSuccessor⽅法⽤于唤醒等待队列中下⼀个线程。
AQS有几种同步方式,实现同步器一般要覆盖哪些方法?

独占式:如ReentrantLock。
共享式:如Semaphore。
存在组合:组合式的如ReentrantReadWriteLock,AQS为使用提供了底层支撑,使用者可以自由组装实现。


  1. boolean tryAcquire(int arg)
  2. boolean tryRelease(int arg)
  3. int tryAcquireShared(int arg)
  4. boolean tryReleaseShared(int arg)
  5. boolean isHeldExclusively() //是否持有锁

不需要全部实现,根据获取的锁的种类可以选择实现不同的⽅法。
如实现支持独占锁的同步器应该实现tryAcquire、 tryRelease、isHeldExclusively。
实现支持共享获取的同步器应该实现tryAcquireShared、tryReleaseShared、
isHeldExclusively。

2、源码剖析并发编程ReentrantLock实现

ReentrantLock 的底层实现

java 多并发面试题 java并发编程面试_Java_02


公平锁和非公平锁核心区别


java 多并发面试题 java并发编程面试_Java_03

3、ReentrantLock和synchronized

ReentrantLock和synchronized使用的场景是什么,实现机制有什么不同?
  • ReentrantLock和synchronized都是独占锁。
  • synchronized:
  • 是悲观锁会引起其他线程阻塞,java内置关键字。
  • 无法判断是否获取锁的状态,锁可重⼊、不可中断、只能是非公平。
  • 加锁解锁的过程是隐式的,用户不用手动操作,优点是操作简单但显得不够灵活。
  • ⼀般并发场景使用足够、可以放在被递归执行的方法上,且不用担行线程最后能否正确
    释放锁。
  • synchronized操作的应该是对象头中mark word。
  • ReentrantLock:
  • 是个Lock接口的实现类,是悲观锁。
  • 可以判断是否获取到锁,可重⼊、可判断、可公平可不公平。
  • 需要手动加锁和解锁,且解锁的操作尽量要放在finally代码块中,保证线程正确释放锁。
  • 在复杂的并发场景中使用在重入时要却确保重复获取锁的次数必须和重复释放锁的次数⼀样,否则可能导致其他线程无法获得该锁。
  • 创建的时候通过传进参数true创建公平锁,如果传入的是false或没传参数则创建的是非公平锁。
  • 底层不同是AQS的state和FIFO队列来控制加锁。

4、ReentrantReadWriteLock

  • 读写锁接口ReadWriteLock接口的⼀个具体实现,实现了读写锁的分离。
  • ⽀持公平和非公平,底层也是基于AQS实现。
  • 允许从写锁降级为读锁。
    流程:先获取写锁,然后获取读锁,最后释放写锁;但不能从读锁升级到写锁。
  • 重⼊:读锁后还可以获取读锁;获取了写锁之后既可以再次获取写锁⼜可以获取读锁
    核心:读锁是共享的,写锁是独占的。 读和读之间不会互斥,读和写、写和读、写和写之间才会互斥,主要是提升了读写的性能
  • 场景:读多写少,如设计⼀个缓存组件 或 提高Collection的并发性。

ReentrantLock是独占锁且可重入的,相比synchronized而言功能更加丰富也更适合复杂的并发场景,但是也有弊端,假如有两个线程A/B访问数据,加锁是为了防⽌线程A在写数据, 线程B在读数据造成的数据不⼀致; 但线程A在读数据,线程C也在读数据,读数据是不会改变数据没有必要加锁,但是还是加锁了,降低了程序的性能,所以就有了ReadWriteLock读写锁接口。

5、阻塞队列BlockingQueue

并发编程里解决生产消费者模型你知道哪几种方式?
  • wait() / notify()⽅法
  • await() / signal()⽅法
    ⽤ReentrantLock和Condition实现等待/通知模型
  • Semaphore信号量
  • BlockingQueue阻塞队列:ArrayBlockingQueue、LinkedBlockingQueue
  • put方法用来向队尾存入元素,如果队列满,则阻塞。
  • take方法用来从队首取元素,如果队列为空,则阻塞。
BlockingQueue与常见的阻塞队列

BlockingQueue: j.u.c包下的提供了线程安全的队列访问的接口,并发包下很多高级同步类的实现都是基于阻塞队列实现的。

  • 当阻塞队列进行插入数据时,如果队列已满,线程将会阻塞等待直到队列非满。
  • 从阻塞队列读数据时,如果队列为空,线程将会阻塞等待直到队列里面是非空的时候。

常见的阻塞队列:

  • ArrayBlockingQueue:基于数组实现的⼀个阻塞队列,需要指定容量大小,FIFO先进先出顺序。
  • LinkedBlockingQueue:基于链表实现的⼀个阻塞队列,如果不指定容量大小,默认Integer.MAX_VALUE, FIFO先进先出顺序。
  • PriorityBlockingQueue:⼀个支持优先级的无界阻塞队列,默认情况下元素采用自然顺序升序排序,也可以自定义排序实现 java.lang.Comparable接口。
  • DelayQueue:延迟队列,在指定时间才能获取队列元素的功能,队列头元素是最接近过期的元素,里面的对象必须实现 java.util.concurrent.Delayed 接口并实现CompareTo和getDelay方法。

6、 非阻塞队列ConcurrentLinkedQueue

ConcurrentLinkedQueue怎么实现线程安全的

线程安全原因:ConcurrentLinkedQueue是基于链表实现的无界线程安全队列,采用FIFO进行排序保证线程安全的三要素:原子性、有序性、可见性。

  • 底层结构是Node,链表头部和尾部节点是head和tail,使用节点变量和内部类属性使用volatile声明保证了有序和可见性
  • 插入、移除、更新操作使用CAS无锁操作,保证了原子性
  • 假如多线程并发修改导致 CAS 更新失败,采用for循环插入保证更新操作成功。

7、并发编程实践

平时多线程用的挺多的,写出3条你遵循的多线程最佳实践
  • 给不同模块的线程起名称,方便后续排查问题。
  • 使用同步代码块或者同步的方法的时候,尽量减小同步范围。
  • 多用并发集合少用同步集合:
    支持线程安全:
  • 同步集合:Hashtable/Vector/同步工具类包装Collections.synXXX。
  • 并发集合:ConcurrentHashMap、CopyOnWriteArrayList。
  • 线上业务需要使用多线程,优先考虑线程池是否更加合适,然后判断哪种线程池比较好,最后才是自己创建单⼀线程。

8、线程池

使用线程池的好处

重用存在的线程,减少对象创建销毁的开销,有效的控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞,且可以定时定期执性、单线程、并发数控制,配置任务过多任务后的拒绝策略等功能。

常用的线程池
  • newFixedThreadPool:⼀个定长线程池,可控制线程最大并发数。
  • newCachedThreadPool:⼀个可缓存线程池
  • newSingleThreadExecutor:⼀个单线程化的线程池,用唯⼀的工作线程来执行任务。
  • newScheduledThreadPool:⼀个定长线程池,支持定时/周期性任务执行。
常见线程池问题

newFixedThreadPool和newSingleThreadExecutor:

  • 队列使用LinkedBlockingQueue,队列⻓度为 Integer.MAX_VALUE,可能造成堆积,导致OOM。

newScheduledThreadPool和newCachedThreadPool:

  • 线程池里允许最大的线程数是Integer.MAX_VALUE,可能会创建过多线程,导致OOM。
【阿里巴巴编码规范】 线程池不允许使用 Executors 去创建,要通过 ThreadPoolExecutor的方式原因?
  • Executors创建的线程池底层也是调用 ThreadPoolExecutor,只不过使用不同的参数、队列、拒绝策略等,如果使用不当,会造成资源耗尽问题。
  • 直接使用ThreadPoolExecutor让使用者更加清楚线程池允许规则,常见参数的使用,避免风险。

9、ThreadPoolExecutor核心参数

public ThreadPoolExecutor(
	int corePoolSize,
 	int maximumPoolSize,
 	long keepAliveTime,
 	TimeUnit unit,
 	BlockingQueue<Runnable> workQueue,
 	ThreadFactory threadFactory,
 	RejectedExecutionHandler handler
 )

corePoolSize:

  • 核心线程数,线程池也会维护线程的最少数量,默认情况下核⼼线程会⼀直存活,即使没有任务也不会受存keepAliveTime控制。
  • 注意:在刚创建线程池时线程不会⽴即启动,到有任务提交时才开始创建线程并逐步线程数达到corePoolSize。

maximumPoolSize:

  • 线程池维护线程的最⼤数量,超过将被阻塞。
  • 注意:当核心线程满,且阻塞队列也满时,才会判断当前线程数是否小于最大线程数,才决定是否创建新线程。

keepAliveTime:

  • 非核心线程的闲置超时时间,超过这个时间就会被回收,直到线程数量等于corePoolSize。

unit:

  • 指定keepAliveTime的单位,如TimeUnit.SECONDS、TimeUnit.MILLISECONDS。

workQueue:

  • 线程池中的任务队列,常用的是 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue。

threadFactory:

  • 创建新线程时使用的工厂。

handler:

  • RejectedExecutionHandler是⼀个接口且只有⼀个方法,线程池中的数量大于maximumPoolSize,对拒绝任务的处理策略。
  • 默认有4种策略AbortPolicy、CallerRunsPolicy、DiscardOldestPolicy、DiscardPolicy。