JUC

一、JUC

1.概念

  从Java 5开始,在JDK中多出了java.util.concurrent包(简称:JUC)。

  JUC主要是让开发者在 多线程编程 中更加简单、方便一些。

  通过JDK内置了一些类、接口、关键字,补充完善了JDK对于并发编程支持的“短板”。

2.主要包含功能

  1. Executor:线程池

  2. Atomic:原子操作类

  3. Lock:锁

  4. Tools:信号量工具类

  5. 并发集合:提供了线程安全的集合类。

二、线程池

1.为何使用线程池

对于频繁创建和销毁的线程会消耗系统大量的资源,此时通过使用线程池可以使系统响应时间更快,消耗资源更少

2.概念

线程池是内存中的一块空间,其中存放着实例化的线程对象

当需要用到线程时从线程池中取出,执行完任务或需要销毁时再放回线程池,而不是让线程处于死亡状态。

3.使用线程池的特点

3.1 优点

  1.降低系统资源的消耗。通过重用线程对象,减低由于新建和销毁线程造成的系统资源消耗

  2.提高系统响应的速度。直接从内存中获取线程,比新建线程更快

  3.提供线程的可管理性。通过对线程池中线程数量的限制,避免无限创建线程导致的内存溢出或CPU资源耗尽等问题。

3.2 缺点

  默认情况下,无论是否需要使用线程,线程池中都有一些线程对象,占用内存。

三、JUC中的线程池

1.Executor

线程池的顶级接口

该接口中只有一个execute方法,参数为Runnable类型

public interface Executor {
  void execute(Runnable command);
}

2.ThreadPoolExecutor

2.1 介绍

是JUC提供的默认的线程池的实现类

2.2 构造方法

四种有参构造

最多7个参数的构造方法

 2.3 参数

  2.3.1 corePoolSize-核心线程数

  创建线程池后,线程池中默认是没有线程的,当从线程池获取线程执行任务时才创建核心线程来执行任务。

  若没有线程数没有达到核心线程数corePoolSize,即使有空闲的核心线程,还是会创建新的核心线程来执行任务,直到达到核心线程数corePoolSize。

  线程池中的线程数达到核心线程数corePoolSize后,从线程池获取空闲的核心线程执行任务。

  2.3.2 workQueue-阻塞队列

  队列:底层是数组或链表实现的,特点是先进先出

  阻塞:队列为空时阻塞获取任务,队列满时阻塞添加任务。

  作用: 当线程池中线程数量达到核心线程数corePoolSize时,且没有空闲的核心线程,再来获取线程执行任务,任务会被添加到缓存任务的阻塞队列workQueue中;

    队列可以设置queueCapacity参数,表示最多能存储的任务数量

  2.3.3 maximumPoolSize-最大线程数

  当所有核心线程都在使用中、阻塞队列的任务也满了,线程池会创建新的线程执行任务,直到线程池中的线程数达到最大线程数maximumPoolSIze;

  线程池中线程数已达到最大线程数且都在使用中、阻塞队列也满了,线程池对于新来的任务就会执行拒绝策略

  2.3.4 keepAliveTime-线程最大空闲时间

  线程池中的空闲线程空闲的时间超过了最大空闲时间keepAliveTime就会被销毁,直到线程池中的线程数等于核心线程数;

  若设置allowCoreThreadTimeOut=true(默认false),核心线程也可以被销毁。

  2.3.5 unit-时间单位

  TimeUnit是枚举类型

public enum TimeUnit {
  //纳秒
  NANOSECONDS(TimeUnit.NANO_SCALE),
  //微妙
  MICROSECONDS(TimeUnit.MICRO_SCALE),
  //毫秒
  MILLISECONDS(TimeUnit.MILLI_SCALE),
  //
  SECONDS(TimeUnit.SECOND_SCALE),
  //分钟
  MINUTES(TimeUnit.MINUTE_SCALE),
  //小时
  HOURS(TimeUnit.HOUR_SCALE),
  //
  DAYS(TimeUnit.DAY_SCALE);
  private static final long NANO_SCALE   = 1L;
  private static final long MICRO_SCALE  = 1000L * NANO_SCALE;
  private static final long MILLI_SCALE  = 1000L * MICRO_SCALE;
  private static final long SECOND_SCALE = 1000L * MILLI_SCALE;
  private static final long MINUTE_SCALE = 60L * SECOND_SCALE;
  private static final long HOUR_SCALE   = 60L * MINUTE_SCALE;
  private static final long DAY_SCALE    = 24L * HOUR_SCALE;
}

  2.3.6 threadFactory-线程工厂

  创建线程对象

  2.3.7 handler-线程池拒绝策略

  前提:只有当任务队列已满,且线程数量已经达到maximunPoolSize才会触发拒绝策略。
  1. AbortPolicy(默认):新提交的任务将被拒绝,并抛出RejectedExecutionException异常。
  2. CallerRunsPolicy:新提交的任务将由提交该任务的线程(调用execute()方法的线程)执行。这意味着提交任务的线程将会暂时充当一个临时线程来执行任务。
  3. DiscardPolicy:新提交的任务将被丢弃,不会抛出任何异常。
  4. DiscardOldestPolicy:新提交的任务将会替换掉等待队列中最旧的任务,然后尝试再次提交该任务。

2.4 创建线程总结

  1. 当线程数小于核心线程数时,创建线程, 直到达到指定的核心线程数。

  2. 当线程数大于等于核心线程数,且任务队列未满时,将任务放入任务队列。

  3. 当线程数大于等于核心线程数,且任务队列已满 。

​     i. 若线程数小于最大线程数,创建线程, 直到达到最大线程数 。

​     ii. 若线程数等于最大线程数,抛出异常,拒绝任务。

3. Excutors

Excutors属于一个工具类,可以快速实例化 特定类型 的线程池对象。

返回值都是ExecutorService接口的实现类,底层都调用了ThreadPoolExecutor()

 3.1 SingleThreadExecutor() 单线程线程池

方法代码如下:

参数:

  核心线程数和最大线程数都为1

  最大空闲时间为0

  底层为链表的阻塞队列;

效果: 只会创建一个线程执行任务。

 3.2 newFixedThreadPool( int ) 固定大小线程池

方法代码:

核心线程数和最大线程数都是指定的nThreads

底层为链表的阻塞队列

实际线程数量永远维持在nThreads。

 3.3 newCachedThreadPoole() 可缓存线程池

 参数:

  核心线程数:0

  最大线程数:Integer的最大值(2^31-1)

  最大空闲时间 60 秒

  底层为同步队列的阻塞队列(没有存储空间,只要有任务就必须要有线程执行,如果没有找到空闲的线程就创建新的线程执行)

效果:

  无限容量、线程空闲时间超过60秒就会被销毁、阻塞队列底层为同步队列,没有存储空间,只要有任务就必须有线程来处理,没有空闲的线程就创建新的线程来执行。

 3.4 newScheduleThreadPool()定时任务线程池

 参数:

  核心线程数为指定的corePoolSize

  最大线程数为Integer最大值

  最大空闲时间0

  阻塞队列:DelayQueue队列

    底层使用数组实现, 初始容量为16, 超过16个任务, 扩容到之前的1.5倍

可以实现 延时执行 和 定期执行

   3.4.1 延时执行 schedule()

public class Test {
  public static void main(String[] args) {
    ScheduledExecutorService ses = Executors.newScheduledThreadPool(10);
    //演示3秒后执行一次
    ses.schedule(new Runnable() {
      @Override
      public void run() {
        System.out.println(new Date());
      }
    }, 3, TimeUnit.SECONDS);

    3.4.2 定期执行 scheduleAtFixedRate()

initialDalay:第一次执行任务延迟的时间

period:每次执行任务的时间间隔

    //延迟1秒后, 每隔3秒执行一个任务
    ses.scheduleAtFixedRate(new Runnable() {
      @Override
      public void run() {
        System.out.println(new Date());
      }
    }, 1, 3, TimeUnit.SECONDS);
  }
}

   3.5 WorkStealingPool(工作窃取线程池)

在 Java 8 中引入了 ForkJoinPool,这是一种特殊类型的线程池,也是工作窃取算法的实现。

ForkJoinPool 中,每个线程都维护着一个工作队列(WorkQueue),线程可以从自己的队列中获取任务执行,也可以从其他线程的队列中“窃取”任务执行,以实现负载均衡。

Java 8 中的 ForkJoinPool 提供了 newWorkStealingPool() 方法,用于创建一个工作窃取线程池。

这种类型的线程池通常用于处理可以被分割成更小子任务的并行任务,其中每个子任务可以被分配给不同的线程执行。

当一个线程完成自己队列中的任务后,它会尝试从其他线程的队列中“窃取”任务来执行,以充分利用系统资源。

工作窃取线程池的特点包括:

  1. 工作窃取算法:采用工作窃取算法,使得空闲线程可以从其他线程的队列中窃取任务来执行,提高了并行任务执行的效率。

  2. 负载均衡:工作窃取线程池能够在多个线程之间动态地分配任务,以确保各个线程的工作负载尽可能均衡,从而提高整体的并行处理能力。

  3. 适用于递归任务:对于可分割的任务以及递归任务,工作窃取线程池能够更好地利用多核处理器的优势,加速任务的执行。

在使用工作窃取线程池时,

可以通过 ForkJoinPool.commonPool() 或者 ForkJoinPool.newWorkStealingPool() 来获取一个默认的工作窃取线程池,

也可以根据需要自定义 ForkJoinPool 的参数来创建自己的线程池实例。

 四、JUC中的AtomicInteger原子类

1.原子性

  操作过程中不允许其他线程干扰,可以理解为数据操作是整体,整体只有成功或失败,不允许出现部分成功部分失败

  只要具备原子性,就一定是线程安全的

2.AtomicInteger的常用方法

getAndIncrement():先获取值再加一

incrementAndGet():先加一再获取值

getAndDecrement():先获取值再减一

decrementAndGet():先减一在获取值

getAndAdd(int):先获取再加指定的值

addAndGet(int):先加指定的值在获取值

set(int):修改atomicInteger为指定的值

get():获取值

getAndSet(int):先获取再修改为指定的值

 AtomicInteger atomicInteger = new AtomicInteger(0);

    //先获取atomicInteger中的值, 再将值+1   num++
    int i = atomicInteger.getAndIncrement();
    System.out.println(i); //0

    //先将值+1, 先获取atomicInteger中的值 ++num
    int i2 = atomicInteger.incrementAndGet();
    System.out.println(i2); //2

    //先获取atomicInteger中的值, 再将值-1   num--
    int i3 = atomicInteger.getAndDecrement();
    System.out.println(i3); //2

    //先将值-1, 先获取atomicInteger中的值 --num
    int i4 = atomicInteger.decrementAndGet();
    System.out.println(i4); //0

    //先获取atomicInteger中的值, 再+指定的值
    int i5 = atomicInteger.getAndAdd(10);
    System.out.println(i5); //0

    ////先+指定的值, 先获取atomicInteger中的值
    int i6 = atomicInteger.addAndGet(20);
    System.out.println(i6); //30

    int i7 = atomicInteger.getAndAdd(-5);
    System.out.println(i7); //30

    int i8 = atomicInteger.addAndGet(-10);
    System.out.println(i8); //15

    //改变atomicInteger中的值
    atomicInteger.set(100);

    int andSet = atomicInteger.getAndSet(200);
    System.out.println(andSet); //100

    //先获取atomicInteger中的值
    int i1 = atomicInteger.get();
    System.out.println(i1); //200

 3.底层实现

原子类AtomicInteger底层通过volatile和CAS实现

4.Volatile关键字

在多线程环境下Volatile可以保证共享数据的可见性、有序性,但不能保证对数据操作的原子性,

所以在多线程环境下是线程不安全的。

可见性:

在JMM中,并不保证一个线程修改变量后,另一个线程能够立即看到这个变化,这是因为每个线程都有自己的工作内存,线程之间不能直接读写彼此的工作内存。

可见性是指一个线程修改的变量对其他线程是可见的。

- 线程操作变量后, 立刻将新的变量值同步回到主内存中.
- 线程使用变量前, 会从主内存中拷贝最新的变量值, 线程正在使用变量, 无法拷贝最近变量值.

如果需要确保可见性,可以使用volatile关键字或者加锁同步。

 有序性:为了提高性能,编译器和处理器会对指令进行重排序

使用volatile关键字修饰变量时,Java 编译器在生成字节码时,会在指令序列中插入内存屏障(CPU处理器的指令)来禁止CPU处理器重排序。

5.CAS算法

CAS(Compare And Swap)比较和交换算法

是一种并发编程中常用的原子操作,用于实现无锁算法。

CAS 操作包含三个参数:内存地址 V、旧的预期值 A 和新的值 B。

它的语义是,如果当前内存地址 V 的值等于预期值 A,则将内存地址 V 的值更新为 B。CAS 操作是原子的,可以在并发环境下实现线程安全的操作。

 

CAS不需要和synchronized一样让对象具有独立性、互斥性保持线程安全。而是一种无锁也可以保证线程安全的算法。

volatile + cas: 可见性 有序性 原子性, 多线程同时操作, 同一时刻多个线程同时操作, 只能一个线程能成功, 其它线程重复执行.(乐观锁)

synchronized: 可见性 有序性 原子性, 多线程同时操作, 同一时刻只能一个线程操作, 其它线程等待.(悲观锁)

基本流程:

  1. 读取内存地址 V 的当前值。
  2. 比较内存地址 V 的当前值和预期值 A 是否相等。
  3. 如果相等,则将内存地址 V 的值更新为新的值 B。
  4. 如果不相等,则说明其他线程已经修改了内存地址 V 的值,操作失败。

5.1 优点

1. 保证数据操作的原子性,保证了线程是安全的。

2. 这个算法相对synchronized是比较“乐观的”,它不会像synchronized一样,当一个线程访问共享数据的时候,别的线程都在阻塞。

  synchronized不管是否有线程冲突都会进行加锁。由于CAS是非阻塞的,它死锁问题天生免疫,并且线程间的相互影响也非常小,

  更重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,所以它要比锁的方式拥有更优越的性能。

5.2 缺点

1. 多线程操作时每次修改值时,可能多次出现内存值和副本值不同的情况,需要循环执行多次

2. 可能出现ABA问题

5.3 ABA问题

ABA 表示一个共享变量由初始值 A 经过一系列操作变为 B,然后再经过一系列操作又回到了 A。

尽管最终共享变量的值没有发生变化,但是在这个过程中如果还有其他线程观察共享变量的变化,可能会导致意外的结果。

解决:加入版本号或标记对共享变量的变化进行跟踪。

6.原子类AtomicInteger源码

6.1 底层实现

 volatile 和 CAS:  

      volatile保证了可见性, 有序性, cas保证了原子性

objectFieldOffset 本地方法

 通过反射获取了 属性value,又调用objectFieldOffset方法得到了属性value的地址并赋给valueOffset

 传入参数 :原子类AtomicInteger的对象,属性value的地址,1

getIntVolatile 本地方法

 调用getIntVolatile方法根据传入的对象和属性地址得到了该对象的属性value的值 var5

 

 调用compareAndSwapInt()方法 传入的参数代表:var1-AtomicInteger对象、var2-value属性的地址、var5副本值(期望值)、var5+var4 新值

①方法会根据var1和var2获取到当前主内存中该对象的value值,并与期望值var5进行比较,

②若相等就将新值赋给主内存中的value,返回修改前的值var5

③若主内存中的value值跟期望值var5不相等,返回false,取反得到true就会进入下一次循环:拿到新的期望值var5再调用compareAndSwapInt方法执行①

直到退出循环。

五、Synchronized锁

1. 锁介绍

  在Java中,任何对象或类都可以当做锁使用,称为内置锁

  Java中内置锁都是互斥锁。也就是说一个线程获取到锁,其他线程必须等待或阻塞。 如果占用锁的线程不释放锁,其他线程将一直等待下去。

  锁在同一时刻,只能被一个线程持有

  如果锁是作用于对象,称对象锁。如果锁作用整个类称为类锁

2. Synchronized

  1. synchronized是Java中的关键字。

  2. synchronized的加锁和解锁过程不需要程序员手动控制,只要执行到synchronized作用范围会自动加锁(获取锁/持有锁),执行完成后会自动解锁(释放锁)

  3. synchronized可以保证可见性,因为每次执行到synchronized代码块时会清空线程区。

  4. synchronized 会不禁用指令重排,但可以保证有序性。因为同一个时刻只有一个线程能操作。

  5. synchronized 可以保证原子性,一个线程的操作一旦开始,就不会被其他线程干扰,只能当前线程执行完,其他线程才可以执行。

  6. synchronized 在Java老版本中属于重量级锁(耗费系统资源比较多的锁),随着Java的不停的更新、优化,在Java8中使用起来和轻量级锁(耗费系统资源比较少的锁)已经几乎无差别了。

  7. 主要分为下面几种情况:

    1. 修饰实例方法,非静态方法(对象锁) 需要在类实例化后,再进行调用。(必须为同一个对象锁才会生效)

    2. 修饰静态方法(类锁)静态方法属于类级别的方法,静态方法可以类不实例化就使用。

    3. 修饰代码块(对象锁、类锁)。

3. 对象锁和类锁(面试题)

当synchronized修饰静态方法或代码块参数为Class时或代码块参数为固定值,锁为类锁,作用整个类。同一个类使用,锁生效。

当synchronized修饰实例方法或代码块参数为this时,为对象锁,只对当前对象有效。

体现在:

多个对象使用时,锁生效,使用类锁。

同一对象使用时,锁生效,使用对象锁。

4. 什么是可重入锁(面试题)

可重入锁(Reentrant Lock)是一种支持同一线程多次获取同一把锁的锁机制。

当线程获得可重入锁后,它可以多次进入由同一把锁保护的同步代码块,而不会被自己所持有的锁所阻塞。

可重入锁的主要特点是线程可以重复地获取锁,而不会因为重复获取而发生死锁或阻塞。这个特性通常由锁维护一个持有计数器来实现。当线程第一次获取锁时,计数器增加;每次成功获取锁后,计数器增加;释放锁时,计数器减少。只有当计数器值减到零时,锁才会完全释放。

可重入锁相对于传统的内置锁(synchronized)具有一些优势:

  1. 递归性:可重入锁支持线程递归调用自身所持有锁的同步代码块,而内置锁不能。

  2. 公平性:可重入锁提供了公平性策略的选择,可以控制锁的获取顺序,尽量避免饥饿现象。

  3. 条件变量:可重入锁支持条件变量的使用,通过 newCondition() 方法创建条件变量,方便实现更复杂的线程间通信和同步。

可重入锁常用于需要在同一线程中多次进入同一个锁保护的代码块的情况,例如递归算法、嵌套 synchronized 块等。使用可重入锁可以简化嵌套锁的管理,提高代码的可读性和可维护性。

Java中的 ReentrantLock 是一种可重入锁的实现。与内置锁相比,ReentrantLock 提供了更高级别的功能,例如可中断锁、超时锁等,更好地满足不同的并发控制需求。但需要注意的是,在使用 ReentrantLock 时需要显式地调用 lock()unlock() 方法来获取和释放锁。

 

synchronized为可重入锁。但可重入锁不仅仅只有synchronized。

ReentrantLock也是可重入锁。

可重入锁底层原理:

  可重入锁底层就是计数器。

  当一个线程第一次持有某个锁时会由monitor(监控器)对持有锁的数量加1,

  当这个线程再次需要碰到这个锁时,如果是可重入锁就对持有锁数量再次加1(如果是不可重入锁,发现持有锁为1了,就不允许多次持有这个锁了,阻塞),

  当释放锁时对持有锁数量减1,直到减为0,表示完全释放了这个锁。

5. 锁的升级

在Java 8之前,synchronized锁被称为重量级锁,因为它在底层使用的是操作系统级别的互斥量(mutex),涉及用户态和内核态的切换。这涉及到高成本的上下文切换和线程阻塞,对性能有一定影响。

然而,从Java 6开始,Java引入了偏向锁、轻量级锁和适应性自旋锁等优化措施,进一步改进了synchronized锁的性能。

在Java 8中,synchronized锁经历了一些重要的优化,以减少锁的获取和释放的开销,尤其在竞争不激烈的情况下。以下是一些锁优化的改进:

  1. 偏向锁(Biased Locking):在没有竞争的情况下,synchronized会自动升级为偏向锁。偏向锁允许一个线程在进入同步块时不再进行加锁和解锁的操作,从而提高了单线程同步的性能。

  2. 轻量级锁(Lightweight Locking):当多个线程尝试获取同一把锁时,锁会逐渐升级为轻量级锁。轻量级锁使用CAS(Compare and Swap)操作来尝试获取锁,避免了进入内核态的开销。

  3. 自旋锁(Spin Locking):在发生轻量级锁竞争时,线程不会立即阻塞等待,而是进行一定次数的自旋,尝试重新获取锁,这期间不进行上下文切换。只有自旋次数达到一定阈值或被其他线程抢占锁时,线程才会阻塞。

注意:锁的升级可能不是单向的。在某些情况下,锁可以从重量级锁降级到轻量级锁,或者从轻量级锁降级到偏向锁,以适应不同的并发场景和线程访问模式

6. 自旋

自旋是一种线程等待的技术,用于在获取锁时避免线程阻塞的情况下尽可能快地获取锁或完成等待。

当一个线程需要获取锁时,如果锁已经被其他线程占用,传统的做法是将线程置于阻塞状态,等待锁的释放。但是,阻塞和唤醒线程涉及到操作系统的上下文切换,具有较高的开销。

相反,自旋技术尝试在获取锁时不进行线程阻塞,而是通过进行一定次数的循环自旋等待来尝试获取锁。在自旋等待期间,线程会反复检查锁状态,如果发现锁已被释放,则可以立即获取锁并继续执行。这样,线程在自旋期间避免了阻塞和唤醒的开销。

自旋的效果在竞争不激烈、锁占用时间短暂的情况下通常是有效的。因为自旋等待通常只会持续很短的时间,所以不会耗费过多的系统资源。然而,在竞争激烈或锁占用时间较长的情况下,自旋等待可能会浪费大量的CPU资源,因为线程在自旋期间会占用CPU而无法执行其他任务。

为了调整自旋等待的时间和策略,可以使用自旋锁的实现来设置自旋等待的次数、条件和延迟。如果自旋等待次数达到上限仍然无法获取锁,线程还是会转为阻塞状态等待锁的释放。

需要注意的是,自旋等待不适用于所有情况。它更适合于执行时间短暂而且发生竞争不频繁的情况。在高度竞争或锁占用时间较长的情况下,更好的选择可能是使用其他同步机制,例如适应性自旋锁、阻塞锁或并发工具类中提供的更高级别的同步原语。

六、线程生命周期

七、线程常用方法

1. stop() 和 interrupt()

stop()方法已过时

stop()在任何情况下都能强行停止一个线程,并且不会提示错误信息,可能会造成混乱的结果,所以被弃用

可以使用interrupt()方法代替stop()方法,可以中断并结束线程

interrupt()方法负责打断阻塞状态的线程,防止出现死锁或长时间等待。

但interrupt()方法只能中断线程状态带有InterruptedException异常的线程。

2.suspend()和resume() (均已过时)

2.1 suspend() 

 suspend()会挂起线程,阻塞线程,不释放锁。已过时

2.2 resume()

 resume() 会唤醒被挂起的线程,进入就绪状态。已过时

由于容易导致死锁,所以均被弃用。

官方解释:

此方法已被弃用,因为它本质上容易死锁。

如果目标线程在挂起时保护关键系统资源的监视器上持有锁,则在目标线程恢复之前,没有线程可以访问该资源。

如果将恢复目标线程的线程在调用resume之前尝试锁定此监视器,则会导致死锁。

这种死锁通常表现为“冻结”进程。

3.wait()和sleep()区别

  1. 所属类不同

  wait(long) 是Object中方法

  sleep(long)是Thread的方法

  1. 唤醒机制不同

  wait() 没有设置最大时间情况下,必须等待notify() | notifyAll()

  sleep()是到指定时间自动唤醒

  1. 锁机制不同

  wait(long)释放锁

  sleep(long)只是让线程休眠,不会释放锁

  1. 使用位置不同

  wait()必须持有对象锁

  sleep()可以使用在任意地方

  1. 方法类型不同

  wait()是实例方法

  sleep()是静态方法

八、线程通信

1.线程通信的几种方式(面试题)

  1. wait()和notify() | notifyAll() 方式

  2. join()方式

  3. Condition 方式

  4. ...

1.1 wait() 和 notify() / notifyAll()

 wait()方法是Object类中的方法

 

wait()方法会让线程变为阻塞,阻塞的同时会释放锁

所以wait()必须要求被等待的线程持有锁,调用wait()后会把锁释放,其他线程竞争获取锁。

当其他线程竞争获取到锁以后,如果达到某个条件后可以通过notify()唤醒,如果有多个wait的线程,系统判断唤醒其中一个。

如果多个处于wait的线程可以使用notifyAll全部唤醒。唤醒后线程处于就绪状态。

需要注意的是:一个线程唤醒其他线程时,要求当前线程必须持有锁

最简易结论:

1. 使用wait()和notify() | notifyAll()要求必须有锁。

2. wait()、notify()、notifyAll() 都是放入锁的代码中。

3. wait()和notify() | notifyAll() 配合使用。

1.2 join()

join() 把线程加入到另一个线程中。在哪个线程内调用join(),就会把对应的线程加入到当前线程中。

join()后,会让当前线程挂起,变成阻塞状态,直到新加入的线程执行完成,当前线程才会继续执行。

相当于新线程插队了,新线程执行完,被插队的线程才能继续执行。

九、JUC的锁机制

1.JUC中的lock包

java.util.concurrent.locks:JUC中对锁支持的工具包 。

2. AQS

2.1 AQS介绍

AQS全名AbstractQueuedSynchronizer,是并发容器JUC(java.util.concurrent)下locks包内的一个类。

它实现了一个FIFO(FirstIn、FisrtOut先进先出)的队列。底层实现的数据结构是一个双向链表

2.2 工作原理

AQS的核心思想为

如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态。

如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是使用队列实现的锁,即将暂时获取不到锁的线程加入到队列中。

AQS使用一个int state成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。

AQS使用CAS对该同步状态进行原子操作实现对其值的修改,当state大于0的时候表示锁被占用,如果state等于0时表示没有占用锁。

3.锁机制

JUC中锁的底层使用的就是AQS

  1. ReentrantLock:Lock接口的实现类,可重入锁。相当于synchronized同步锁。

  2. ReentrantReadWriteLock:ReadWriteLock接口的实现类。类中包含两个静态内部类,ReadLock读锁、WriteLock写锁。

  3. Condition:是一个接口,都是通过lock.newCondition()实例化。属于wait和notify的替代品。提供了await()、signal()、singnalAll()与之对应。

  4. LockSupport:和Thread中suspend()和resume()相似。

AQS 提供了两种锁机制,分别是排它锁,和 共享锁。
排它锁,就是存在多线程竞争同一共享资源时,同一时刻只允许一个线程访问该共享资
源,也就是多个线程中只能有一个线程获得锁资源,比如 Lock 中的 ReentrantLock 重
入锁实现就是用到了 AQS 中的排它锁功能。
共享锁也称为读锁,就是在同一时刻允许多个线程同时获得锁资源,比如
CountDownLatch 和 Semaphore 都是用到了 AQS 中的共享锁功能

4. 锁机制详解

4.1 ReentrantLock重入锁

ReentrantLock是JUC中对重入锁的标准实现。作用相当于synchronized。

加锁和解锁过程都需要由程序员手动控制,使用很灵活。

提供了2种类型的构造方法。

1. ReentrantLock():创建非公平锁的重入锁。

2. ReentrantLock(boolean)(默认false):创建锁。取值为true表示公平锁,取值为false表示非公平锁。

  公平锁:多线程操作共一个资源时,严格按照顺序执行。

  非公平锁:多线程在等待时,可以竞争,谁竞争成功,谁获取锁。

非公平锁的效率要高于公平锁。ReentrantLock默认就是非公平锁。

语法

创建

  ReentrantLock rk = new ReentrantLock();

 加锁

  两种加锁方法

//无返回值  阻塞代码
rk.lock(); 
//有返回值 不会阻塞代码
boolean b = rk.tryLock()

 释放锁

rk.unLock();

注意事项  

  1. ReentrantLock出现异常时,不会自动解锁

  2. 多线程的情况下,一个线程出现异常,并没有释放锁,其他线程也获取不到锁,容易出现死锁

  3. 建议把解锁方法finally{}代码块中

  4. synchronized加锁与释放锁不需要手动的设置,遇到异常时,会自动的解锁

4.2 Condition 等待/唤醒

wait和notify是针对synchronized的,Condition是针对Lock的

 语法

 创建

ReentrantLock rk = new ReentrantLock();
Condition condition = rk.newCondition();

 线程等待

condition.await(); 

 唤醒一个/所有

condition.signal(); //唤醒一个线程
condition.signalAll(); //唤醒所有线程

4.3 ReadWriteLock 读写锁

ReadWriteLock为接口,实现类为ReentrantReadWriteLock

ReadLock 读锁,又称为共享锁。允许多个线程同时获取该读锁,前提是没有线程持有写锁。

WriteLock 写锁,又称为独占锁。只有一个线程能获取,其他线程无法获取读锁或写锁,避免死锁

工作原理

读写锁的基本工作原理如下:

  • 当没有线程持有写锁时,多个线程可以同时持有读锁,进行读操作。这样可以提高并发性能,因为读操作之间不会相互影响。

  • 当有线程持有写锁时,其他线程无法获取写锁或读锁。这样可以确保写操作的独占性,避免了并发写操作导致的数据不一致性。

  • 当有线程持有读锁时,其他线程可以继续获取读锁,但无法获取写锁。这样可以保证在读操作过程中不会有写操作对共享资源进行修改。

语法

    创建

ReentrantReadWriteLock rk = new ReentrantReadWriteLock();

    读锁

//获取读锁
ReentrantReadWriteLock.ReadLock readLock = rrw.readLock();
//加锁
readLock.lock();
boolean b = readLock.tryLock();
//解锁
readLock.unlock();

    写锁

//获取写锁
ReentrantReadWriteLock.WriteLock writeLock = rrw.writeLock();
//加锁
writeLock.lock();
boolean b = writeLock.tryLock();
//解锁
writeLock.unlock();

4.4 LockSupport 暂停 | 恢复

LockSupport是Lock中实现线程暂停和线程恢复。suspend()和resume()是synchronized中的暂停和恢复。

注意:暂停不会释放锁,避免死锁问题

语法

 暂停

LockSupport.park();

 恢复

LockSupport.unpark(t1);

5.synchronized和lock的区别(面试题)

  1. 类型不同

    synchronized是关键字。修饰方法,修饰代码块

    Lock是接口

  1. 加锁和解锁机制不同

    synchronized是自动加锁和解锁,程序员不需要控制。

    Lock必须由程序员控制加锁和解锁过程,解锁时,需要注意出现异常不会自动解锁

  1. 异常机制

    synchronized碰到没有处理的异常,会自动解锁,不会出现死锁。

    Lock碰到异常不会自动解锁,可能出现死锁。所以写Lock锁时都是把解锁放入到finally{}中。

  1. Lock功能更强大

    Lock里面提供了tryLock()/isLocked()方法,进行判断是否上锁成功。而synchronized是一个关键字,没有相关方法。

  1. Lock性能更优

    如果多线程竞争锁特别激烈时,Lock的性能更优。如果竞争不激烈,性能相差不大。

  1. 线程通信方式不同

    synchronized 使用wait()和notify()线程通信。

    Lock使用Condition的await()和signal()通信。

  1. 暂停和恢复方式不同

    synchronized 使用suspend()和resume()暂停和恢复,这俩方法过时了。

    Lock使用LockSupport中park()和unpark()暂停和恢复,这俩方法没有过时。

十、并发集合类

1.介绍

并发集合类:主要是提供线程安全的集合。

比如:

  1. ArrayList对应的并发类是CopyOnWriteArrayList

  2. HashSet对应的并发类是 CopyOnWriteArraySet

  3. HashMap对应的并发类是ConcurrentHashMap

这些类的方法API和之前学习的ArrayList、HashSet、HashMap的API是相同的,所以重在实现原理上,而不是API的使用上。

2. CopyOnWriteArrayList

2.1 ArrayList

ArrayList是最常用的集合之一,大小不固定,可以随着元素的增多可以自动扩容。

储存的数据为  有序,可重复. 底层实现是基于数组,线程不安全。(Vetor线程安全,但开销太大)

2.2. CopyOnWriteArrayList

使用方式和ArrayList相同, 当时CopyOnWriteArrayList线程为安全的。

写时复制

通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,

然后往新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。

这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。

所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

对于读操作远远多于写操作的应用非常适合,特别在并发情况下,可以提供高性能的并发读取。

CopyOnWrite容器只能保证数据的最终一致性,不能保证数据实时一致性。

所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。

重点源码

public class CopyOnWriteArrayList<E> implements List<E>,RandomAccess, Cloneable, java.io.Serializable {
  //创建不可改变的对象
  final transient Object lock = new Object();
  //volatile修饰的Object类型的数组, 保证了数组的可见性,有序性
  private transient volatile Object[] array;

  //获取元素,根据下标获取元素,支持多线程查询
  public E get(int index) {
    return elementAt(getArray(), index);
  }   

  //设置数组
  final void setArray(Object[] a) {
    array = a;
  }

  //添加元素,写时复制
  public boolean add(E e) {
    //加锁
    synchronized (lock) {
      //获取当前数组
      Object[] es = getArray();
      //获取数组的长度
      int len = es.length;
      //复制旧数组,长度+1,创建一个新数组
      es = Arrays.copyOf(es, len + 1);
      //根据下标,将添加的元素放入
      es[len] = e;
      //将新数组设置为当前的数组
      setArray(es);
      return true;
    }
  }

  //修改元素
  public E set(int index, E element) {
    //加锁
    synchronized (lock) {
      //获取当前数组
      Object[] es = getArray();
      //根据传递的下标,获取数组中的元素
      E oldValue = elementAt(es, index);
      //数组中该下标存储的元素和修改的元素不一致
      if (oldValue != element) {
        es = es.clone();
        //修改元素
        es[index] = element;
      }
      //将新数组设置为当前的数组
      setArray(es);
      return oldValue;
    }
  }
    
  //删除元素
  public E remove(int index) {
    //加锁
    synchronized (lock) {
      //获取当前数组
      Object[] es = getArray();
      //获取数组长度
      int len = es.length;
      //根据传递的下标,获取数组中的元素
      E oldValue = elementAt(es, index);
      int numMoved = len - index - 1;
      Object[] newElements;
      //最有一个元素
      if (numMoved == 0)
        newElements = Arrays.copyOf(es, len - 1);
      else {
        newElements = new Object[len - 1];
        System.arraycopy(es, 0, newElements, 0, index);
        System.arraycopy(es, index + 1, newElements, index,
                         numMoved);
      }
      setArray(newElements);
      return oldValue;
    }
  }
}

3. CopyOnWriteArraySet 源码分析

3.1 HashSet

HashSet 无序、无下标、元素不可重复、线程不安全(底层为HashMap)

3.2 CopyOnWriteArrayList

它是线程安全的HashSet,通过动态数组实现的而不是散列表。

CopyOnWriteArraySet在CopyOnWriteArrayList 的基础上使用了Java的装饰模式,所以底层是相同的。

而CopyOnWriteArrayList本质是个动态数组队列,所以CopyOnWriteArraySet相当于通过动态数组实现的Set,

CopyOnWriteArrayList中允许有重复的元素;但CopyOnWriteArraySet是一个Set集合,所以它不能有重复数据。

因此,CopyOnWriteArrayList额外提供了addIfAbsent()和addAllAbsent()这两个添加元素的API,通过这些API来添加元素时,只有当元素不存在时才执行添加操作!

4. ConcurrentHashMap

4.1 HashMap

HashMap线程不安全

在HashMap中,底层实现为哈希表,系统会根据hash算法来计算key的存储位置,我们可以通过key快速地存、取value,允许一个key-value为null

1. HashMap JDk1.7以及1.7之前

HashMap 底层是基于数组+链表 组成的

头插

2. HashMap JDk1.8以及1.8之后

HashMap 底层是基于 数组+链表+红黑树 组成的,当 Hash 冲突严重时,在数组上形成的链表会变的越来越长,这样在查询时的效率就会越来越低,达到一定的条件,就会由链表转换为红黑树,提高查询的效率

尾插

4.2 HashTable

HashTable和HashMap的实现原理几乎一样,差别无非是

1. HashTable不允许key和value为null

2. HashTable是线程安全的,但是HashTable线程安全的策略实现代价却太大了,简单粗暴,get/put所有相关操作都是synchronized的,

这相当于给整个哈希表加了一把大锁,多线程访问时,只要有一个线程访问操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差

 

4.3 ConcurrentHashMap

 JDK1.7及之前

ConcurrentHashMap采用了非常精妙的"分段锁"策略。

Segment继承了ReentrantLock,所以它就是一种可重入锁(ReentrantLock)。

在ConcurrentHashMap,一个Segment就是一个子哈希表,Segment里维护了一个HashEntry数组,

并发环境下,使用多个锁来控制对hash表的不同部分(段segment)进行的修改,

如果多个修改操作发生在不同的段上,他们就可以并发进行,从而提高了效率。

JDK1.8及之后

ConcurrentHashMap在JDK8中进行了巨大改动。

它摒弃了Segment(锁段)的概念,而是启用了一种全新的方式实现,利用synchronized + CAS,

如果没有出现hash冲突,使用CAS直接添加数据,

只有出现hash冲突的时候才会使用同步锁添加数据,又提升了效率,

它底层由"数组"+链表+红黑树的方式思想(JDK8中HashMap的实现), 为了做到并发,又增加了很多辅助的类,例如TreeBin,Traverser等对象内部类。

源码

final V putVal(K key, V value, boolean onlyIfAbsent) {
  //key-value都为空, 抛出异常
  if (key == null || value == null) throw new NullPointerException();
  //计算key的hash值
  int hash = spread(key.hashCode());
  /*
   * 使用链表保存时,binCount记录结点数;
   */
  int binCount = 0;
  //循环遍数组
  for (Node<K,V>[] tab = table;;) {
    Node<K,V> f; int n, i, fh; K fk; V fv;
    //判断当前桶是否为空,空的就需要初始化
    if (tab == null || (n = tab.length) == 0)
      tab = initTable();
    //计算 key 的 hash 值,通过(n - 1) & hash计算key存放的位置, 存储的位置为空,使用cas直接插入
    else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
      if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
        break;                   // no lock when adding to empty bin
    }
    //发现是ForwardingNode结点,说明此时table正在扩容,则尝试协助数据迁移
    else if ((fh = f.hash) == MOVED)
      tab = helpTransfer(tab, f);
    else if (onlyIfAbsent // check first node without acquiring lock
             && fh == hash
             && ((fk = f.key) == key || (fk != null && key.equals(fk)))
             && (fv = f.val) != null)
      return fv;
    else { //出现hash冲突,也就是table[i]桶中已经曾经添加了Node节点,加锁,添加数据
      V oldVal = null;
      synchronized (f) {
        if (tabAt(tab, i) == f) {
          if (fh >= 0) {
            binCount = 1;
            for (Node<K,V> e = f;; ++binCount) {
              K ek;
              // 出现hash冲突,就会找到“相等”的结点,判断是否需要更新value值
              if (e.hash == hash &&
                  ((ek = e.key) == key ||
                   (ek != null && key.equals(ek)))) {
                oldVal = e.val;
                if (!onlyIfAbsent)
                  e.val = value;
                break;
              }
              Node<K,V> pred = e;
              if ((e = e.next) == null) {
                //插入数据
                pred.next = new Node<K,V>(hash, key, value);
                break;
              }
            }
          }
          //如果当前桶为红黑树,那就要按照红黑树的方式写入数据
          else if (f instanceof TreeBin) {
            Node<K,V> p;
            binCount = 2;
            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                  value)) != null) {
              oldVal = p.val;
              if (!onlyIfAbsent)
                p.val = value;
            }
          }
          else if (f instanceof ReservationNode)
            throw new IllegalStateException("Recursive update");
        }
      }
      // 如果链表中节点个数达到阈值,数组长度大于64,链表转化为红黑树
      if (binCount != 0) {
        if (binCount >= TREEIFY_THRESHOLD)
          treeifyBin(tab, i);
        if (oldVal != null)
          return oldVal;
        break;
      }
    }
  }
  // 计数值加1
  addCount(1L, binCount);
  return null;
}

十一、JUC中的Tools

 Tools是JUC中的工具类,其中包含了CountDownLatch、CyclicBarrier、Semaphore

1. CountDownLatch计数器

在开发中经常遇到在主线程中开启多个线程去并行执行任务,并且主线程需要等待所有子线程执行完毕后再进行汇总的场景。

之前是使用join() | 主线程休眠实现的,但是不够灵活,某些场合和还无法实现,

所以开发了CountDownLatch这个类。底层基于AQS。

CountDown是计数递减的意思,Latch是门闩的意思。内部维持一个递减的计数器。

可以理解为初始有n个Latch,等Latch数量递减到0的时候,结束阻塞,执行后续操作。

创建

CountDownLatch cdl= new CountDownLatch(int);

 

线程等待

//当前线程等待,直到到Latch计数到零,或者被interrupt
cdl.await():

 

计数器递减

//减少Latch的计数,如果计数达到零,释放等待的线程
cdl.countDown( ):

2.CycilcBarrier 回环屏障

CountDownLatch优化了join()在解决多个线程同步时的能力,但CountDownLatch的计数器是一次性的。计数递减为0之后,再调用countDown()、await()将不起作用。

为了满足计数器可以重置的目的,JDK推出了CyclicBarrier类。

await()方法表示当前线程执行时计数器值不为0则等待。如果计数器为0则继续执行。每次await()之后计算器会减少一次。当减少到0下次await从初始值重新递减。

3.Semaphore 信号量

CountDownLatch和CyclicBarrier的计数器递减的,而Semaphore的计数器是可加可减的,并可指定计数器的初始值,并且不需要事先确定同步线程的个数,等到需要同步的地方指定个数即可。

且Semaphore也具有回环重置的功能,这一点和CyclicBarrier很像。底层也是基于AQS。

语法:

创建:

Semaphore sp= new Semaphore(数字);

获取信号量的值:

int i = sp.availablePermits();

增加信号量:

//信号量+1  
sp.release();  
//信号量+n
sp.release(n); 

减少信号量:

sp.acquire(); //信号量-1,无返回值 
sp.tryAcquire(); //信号量-1,有返回值
​
sp.acquire(n); //信号量-n,无返回值 
sp.tryAcquire(n); //信号量-n,有返回值

 

 

posted @ 2023-08-29 20:04  ygdgg  阅读(217)  评论(0)    收藏  举报