likes
comments
collection
share

Thread相关(线程池)、锁、CAS、AQS

作者站长头像
站长
· 阅读数 3

线程Thread相关

1. sleep和wait有什么区别?

1.1 sleep

  1. sleep是线程的方法
  2. sleep方法只让出了CPU,而并不会释放同步资源锁
  3. sleep到时间自动恢复
  4. sleep可以在任意地方调用

1.2 wait

  1. wait作用于对象,是Object的方法
  2. wait方法在让出CPU资源的同时,会释放同步资源锁,此时线程便会处于该对象的等待池
  3. wait需要调用notify/notifyAll从等待池中唤醒中唤醒线程。当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或 notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用了notify后只要一个线程会由等待池进入锁池,而notifyAll会将该对象等待池内的所有线程移动到锁池中,等待锁竞争。
  4. wait()方法只能在同步方法或同步块中使用;

2. 死锁

两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。

public void add(int m) {
    synchronized(lockA) { // 获得lockA的锁
        this.value += m;
        synchronized(lockB) { // 获得lockB的锁
            this.another += m;
        } // 释放lockB的锁
    } // 释放lockA的锁
}

public void dec(int m) {
    synchronized(lockB) { // 获得lockB的锁
        this.another -= m;
        synchronized(lockA) { // 获得lockA的锁
            this.value -= m;
        } // 释放lockA的锁
    } // 释放lockB的锁
}

对于上述代码,线程1和线程2如果分别执行add()dec()方法时:

  • 线程1:进入add(),获得lockA
  • 线程2:进入dec(),获得lockB。 随后:
  • 线程1:准备获得lockB,失败,等待中;
  • 线程2:准备获得lockA,失败,等待中。

此时两个线程进入死锁状态,无解,只能kill掉JVM

3. Java内存结构

  • 堆(Heap):线程共享。所有的对象实例以及数组都要在堆上分配。回收器主要管理的对象。
  • 方法区(Method Area):线程共享。存储类信息、常量、静态变量、即时编译器编译后的代码。
  • 虚拟机栈(JVM Stack):线程私有。存储局部变量表、操作栈、动态链接、方法出口,对象指针。
  • 本地方法栈(Native Method Stack):线程私有。为虚拟机使用到的Native方法服务。如Java使用c或者c++编写的接口服务时,代码在此区运行。
  • 程序计数器(Program Counter Register):线程私有。有些文章也翻译成PC寄存器(PC Register),同一个东西。它可以看作是当前线程所执行的字节码的行号指示器。指向下一条要执行的指令。 直接参考文章看吧:(他文章的名字叫可能不太对,文章讲内存结构讲的还行) JVM内存模型总结,有各版本JDK对比

4. Java内存模型

Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能得到一致效果的机制及规范。目的是解决由于多线程通过共享内存进行通信时,存在的原子性、可见性(缓存一致性)以及有序性问题。

4.1 原子性

是指在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。

线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。所以在多线程场景下,就会发生原子性问题。因为线程在执行一个读改写操作时,在执行完读改之后,时间片耗完,就会被要求放弃CPU,并等待重新调度。这种情况下,读改写就不是一个原子操作。即存在原子性问题。

  • 在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。

4.2 可见性(缓存一致性)

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

在多核CPU,多线程的场景中,每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。 在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。

  • volatile 通过内存屏障和禁止指令重排序来保证可见性的。 (a)、对volatile进行读操作,会在读操作之前增加一个load屏障指令 (b)、对volatile进行写操作,会在写操作之后增加一个store屏障指令 内存屏障:处理器的一组指令,用于实现对内存操作的顺序限制(指令重排时不能把后面的指令重排列到内存屏障之前的位置)
  • synchronized 对一个变量执行unlock操作之前,必须把此变量同步回主内存中(执行store、write操作)
  • final 变量从初始化就不可变

4.3 有序性

即程序执行的顺序按照代码的先后顺序执行。 除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add 。这就是有序性问题。

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。

  • volatile 关键字会禁止指令重排。
  • synchronized 关键字保证同一时刻只允许一条线程操作。

线程池相关

1. ThreadPoolExecutor - 线程池的根本

1. 构造函数

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

corePoolSize: 线程池中核心线程数的最大值 maximumPoolSize: 线程池中能拥有最多线程数 keepAliveTime: 表示空闲线程的存活时间 unit: 表示keepAliveTime的单位 workQueue: 用于缓存任务的阻塞队列 threadFactory: 指定创建线程的工厂 handler: 表示当workQueue已满,且池中的线程数达到maximumPoolSize时,线程池拒绝添加新任务时采取的策略

workQueue需要说明一下,有三种类型

  1. 直接提交 SynchronousQueue
  2. 无界队列 LinkedBlockingQueue
  3. 有界队列 ArrayBlockingQueue 详细知识点可以参考: 线程池底层队列详解 详细知识点可以参考: 线程池ThreadPoolExecutor、Executors参数详解与源代码分析

2. 线程池里的线程创建的策略

当一个任务被提交到线程池之后: (1)如果没有空闲的线程执行该任务且当前运行的线程数少于 corePoolSize,则添加新的线程执行该任务。 (2)如果没有空闲的线程执行该任务且当前的线程数等于 corePoolSize同时阻塞队列未满,则将任务入队列,而不添加新的线程。 (3)如果没有空闲的线程执行该任务且阻塞队列已满同时池中的线程数小于 maximumPoolSize,则创建新的线程执行任务。 (4)如果没有空闲的线程执行该任务且阻塞队列已满同时池中的线程数等于 maximumPoolSize,则根据构造函数中的handler指定的策略来拒绝新的任务。 (5)如果有空闲的线程,但是当前线程池中的线程数少于corePoolSize,添加新的线程执行该任务。线程池虽说默认是懒创建线程,但是它实际是想要快速拥有核心线程数的线程。

详细知识点可以参考: 线程池ThreadPoolExecutor、Executors参数详解与源代码分析

线程池为什么设计为队列满+核心线程数满了才创建新线程?而不是队列积压一定阈值的时候创建新的线程?

2. Executors中的线程池的工厂方法

  1. newCachedThreadPool :创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,否则新建线程。(线程最大并发数不可控制)用于并发执行大量短期的小任务,或者是负载较轻的服务器
  2. newFixedThreadPool:创建一个固定大小的线程池,可控制线程最大并发数,超出的线程会在队列中等待。用于负载比较重的服务器,为了资源的合理利用,需要限制当前线程数量
  3. newScheduledThreadPool : 创建一个定时线程池,支持定时及周期性任务执行。用于需要多个后台线程执行周期任务,同时需要限制线程数量的场景
  4. newSingleThreadExecutor :创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。用于串行执行任务的场景,每个任务必须按顺序执行,不需要并发执行。 详细知识点可以参考: Executors类创建四种常见线程池

1. 锁有哪些种类?有什么区别?

参考:不可不说的Java“锁”事

1.1 乐观锁 VS 悲观锁

乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁。乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS自旋实现。

悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。

1.2 自旋锁 VS 适应性自旋锁

自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

适应性自旋锁自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

1.3 无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁

这四种锁是指锁的状态,专门针对synchronized的。具体的知识点可以看下面的synchronized实现

1.4 公平锁 VS 非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。区别仅仅在线程进入队列之前是否可以插队,进入了队列就都一样了。 ReentrantLock有公平锁和非公平锁两种,默认非公平锁 synchronized是非公平锁

1.5 可重入锁 VS 非可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

1.6 独享锁 VS 共享锁

独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。

共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。 通过ReentrantLock和ReentrantReadWriteLock的源码来实现独享锁和共享锁

2. 锁在Java中的具体实现都有哪些?

2.1 synchronized

非公平锁 可重入锁 独享锁 参考: synchronized详解

2.1.1 synchronized的作用

  • 原子性
  • 可见性
  • 有序性

2.1.2 synchronized的使用

  • 修饰实例方法: 锁对象为当前对象(this)
  • 修饰静态方法: 锁对象为this.class 也就是给当前类加锁,会作用于类的所有对象实例
  • 修饰代码块: 指定加锁对象,对给定对象/类加锁

2.1.3 synchronized的原理

先看两个概念:

  • Java对象头 synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,而Java对象头又是什么呢? 我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

  • Monitor

Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。

Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

synchronized代码反编译后可以看到monitorentermonitorexit指令 MonitorenterMonitorexit指令,会让对象在执行,使其锁计数器加1或者减1。每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的Monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一:

  • monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待
  • 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加
  • 这把锁已经被别的线程获取了,等待锁释放 所以synchronized的可重入原理是靠monitor计数器值的增减来实现的

2.1.4 synchronized是重量级锁吗?synchronized都有哪些状态?

Jdk6优化后不再是重量级锁。 存在锁升级。

锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)

无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。

当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

轻量级锁

是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。

拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。

如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。

如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

重量级锁

升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

2.2 Lock和ReentrantLock

JDK5中新增了Lock接口以及接口的实现类:ReentrantLock

  • ReentrantLock 默认使用非公平锁。也可以通过构造器来显示的指定使用公平锁 可重入锁。非可重入锁可以用NonReentrantLock 独享锁。共享锁可以用ReentrantReadWriteLock

实现和变种太多了,详细了解请自动搜索吧。

2.3 Synchronized与Lock优缺点

synchronized的缺陷

  • 效率低:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时
  • 不够灵活:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活
  • 无法知道是否成功获得锁,相对而言,Lock可以拿到状态,如果成功获取锁,....,如果获取失败,.....

Lock的缺陷

  • 采用synchronized方式不需要用户去手动释放锁,当synchronized方法或者synchronized代码块执行完之后,系统会自动让线程释放对锁的占用;Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。

2.3 CAS

参考:并发编程的基石——CAS机制

CAS,是Compare and Swap的简称,在这个机制中有三个核心的参数:

  • 主内存中存放的共享变量的值:V(一般情况下这个V是内存的地址值,通过这个地址可以获得内存中的值)
  • 工作内存中共享变量的副本值,也叫预期值:A
  • 需要将共享变量更新到的最新值:B

主存中保存V值,线程中要使用V值要先从主存中读取V值到线程的工作内存A中,然后计算后变成B值,最后再把B值写回到内存V值中。多个线程共用V值都是如此操作。CAS的核心是在将B值写入到V之前要比较A值和V值是否相同,如果不相同证明此时V值已经被其他线程改变,重新将V值赋给A,并重新计算得到B,如果相同,则将B值赋给V。

值得注意的是CAS机制中的这步步骤是原子性的(从指令层面提供的原子操作),所以CAS机制可以解决多线程并发编程对共享变量读写的原子性问题。

缺点

1. ABA问题 ABA问题:CAS在操作的时候会检查变量的值是否被更改过,如果没有则更新值,但是带来一个问题,最开始的值是A,接着变成B,最后又变成了A。经过检查这个值确实没有修改过,因为最后的值还是A,但是实际上这个值确实已经被修改过了。为了解决这个问题,在每次进行操作的时候加上一个版本号,每次操作的就是两个值,一个版本号和某个值,A——>B——>A问题就变成了1A——>2B——>3A。在jdk中提供了AtomicStampedReference类解决ABA问题,用Pair这个内部类实现,包含两个属性,分别代表版本号和引用,在compareAndSet中先对当前引用进行检查,再对版本号标志进行检查,只有全部相等才更新值。

2. 可能会消耗较高的CPU 看起来CAS比锁的效率高,从阻塞机制变成了非阻塞机制,减少了线程之间等待的时间。每个方法不能绝对的比另一个好,在线程之间竞争程度大的时候,如果使用CAS,每次都有很多的线程在竞争,也就是说CAS机制不能更新成功。这种情况下CAS机制会一直重试,这样就会比较耗费CPU。因此可以看出,如果线程之间竞争程度小,使用CAS是一个很好的选择;但是如果竞争很大,使用锁可能是个更好的选择

3. 不能保证代码块的原子性 Java中的CAS机制只能保证共享变量操作的原子性,而不能保证代码块的原子性(这是时候就需要synchronied锁了,你会发现synchronied真的是万能的)。

优点

  • 可以保证变量操作的原子性;
  • 并发量不是很高的情况下,使用CAS机制比使用锁机制效率更高;
  • 在线程对共享资源占用时间较短的情况下,使用CAS机制效率也会较高。

2.4 AQS

参考:从ReentrantLock的实现看AQS的原理及应用

AQS全称:AbstractQueuedSynchronizer,抽象队列式同步器 AQS是一个抽象类,它定义了一套多线程访问共享资源的同步器框架。通俗解释,AQS就像是一个队列管理员,当多线程操作时,对这些线程进行排队管理。

AQS主要通过维护了两个变量来实现同步机制的

  • state AQS使用一个volatile修饰的私有变量来表示同步状态,当state=0表示释放了锁,当state>0表示获得锁。
  • FIFO同步队列 AQS通过内置的FIFO同步队列,来实现线程的排队工作。 如果线程获取当前同步状态失败,AQS会将当前线程的信息封装成一个Node节点,通过CAS方式加入同步队列中。当同步状态释放,则会将队列中的线程唤醒,重新尝试获取同步状态。