并发(多线程)进阶学习

249 阅读14分钟

一 ThreadLocal

1.1. ThreadLocal 简介

通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢? JDK 中提供的ThreadLocal类正是为了解决这样的问题。 ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。

如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。

1.2. ThreadLocal 原理

从 Thread类源代码入手。

public class Thread implements Runnable {
 ......
//与此线程有关的ThreadLocal值。由ThreadLocal类维护
ThreadLocal.ThreadLocalMap threadLocals = null;

//与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
 ......
}

从上面Thread类 源代码可以看出Thread 类中有一个 threadLocals 和 一个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,我们可以把 ThreadLocalMap 理解为ThreadLocal 类实现的定制化的 HashMap。默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal 类的 set或get方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap类对应的 get()、set()方法。

ThreadLocal类的set()方法

 public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

通过上面这些内容,我们足以通过猜测得出结论:最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。 ThrealLocal 类中可以通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t)可以访问到该线程的ThreadLocalMap对象。

每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对。

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
 ......
}

比如我们在同一个线程中声明了两个 ThreadLocal 对象的话,会使用 Thread内部都是使用仅有那个ThreadLocalMap 存放数据的,ThreadLocalMap的 key 就是 ThreadLocal对象,value 就是 ThreadLocal 对象调用set方法设置的值。

1.3. ThreadLocal 内存泄露问题

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后 最好手动调用remove()方法

 static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }

ThreadLocal学习链接:juejin.cn/post/695933…

二 CAS(Compare And Swap)原理分析

字面意思是比较和交换

CAS是一种无锁算法。有3个操作数:内存值V、旧的预期值A、要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

do{
  备份旧数据;
  基于旧数据构造新数据;
}while(!CAS( 内存地址,备份的旧数据,新数据 ))

该操作是一个原子操作,被广泛的应用在Java的底层实现中。

在Java中,CAS主要是由sun.misc.Unsafe这个类通过JNI调用CPU底层指令实现。

CAS 优点:资源竞争不大的场景系统开销小。

CAS 缺点:

  • 如果 CAS 长时间操作失败,即长时间自旋,会导致 CPU 开销大,但是可以使用 CPU 提供的 pause 指令,这个 pause 指令可以让自旋重试失败时 CPU 先睡眠一小段时间后再继续自旋重试 CAS 操作,jvm 支持 pause 指令,可以让性能提升一些。

  • 存在 ABA 问题,即原来内存地址的值是 A,然后被改为了 B,再被改为 A 值,此时 CAS 操作时认为该值未被改动过,ABA 问题可以引入版本号来解决,每次改动都让版本号 +1。Java 中处理 ABA 的一个方案是 AtomicStampedReference 类,它是使用一个 int 类型的字段作为版本号,每次修改之前都先获取版本号和当前线程持有的版本号比对,如果一致才进行修改操作,并把版本号 +1。

  • 无法保证代码块的原子性,CAS 只能保证单个变量的原子性操作,如果要保证多个变量的原子性操作就要使用悲观锁了。

三 AQS(AbstractQueuedSynchronizer)原理分析

字面意思是抽象的队列同步器,AQS 是一个同步器框架,它制定了一套多线程场景下访问共享资源的方案,Java 中很多同步类底层都是使用 AQS 实现,比如:ReentrantLock、CountDownLatch、ReentrantReadWriteLock,这些 java 同步类的内部会使用一个 Sync 内部类,而这个 Sync 继承了 AbstractQueuedSynchronizer 类,这是一种模板方法模式,所以说这些同步类的底层是使用 AQS 实现。

image.png

AQS 内部维护了一个 volatile 修饰的 int state 属性(共享资源)和一个先进先出的线程等待队列(即多线程竞争共享资源时被阻塞的线程会进入这个队列)。因为 state 是使用 volatile 修饰,所以在多线程之间可见,访问 state 的方式有 3 种,getState()、setState()和 compareAndSetState()。

AQS 定义了 3 种资源共享方式:

  • 独占锁(exclusive),保证只有一条线程执行,比如 ReentrantLock、AtomicInteger。

  • 共享锁(shared),允许多个线程同时执行,比如 CountDownLatch、Semaphore。

  • 同时实现独占和共享,比如 ReentrantReadWriteLock,允许多个线程同时执行读操作,只允许一条线程执行写操作。

ReentrantLock 和 CountDownLatch 都是自定义同步器,它们的内部类 Sync 都是继承了 AbstractQueuedSynchronizer,独占锁和共享锁的区别在于各自重写的获取和释放共享资源的方式不一样,至于线程获取资源失败、唤醒出队、中断等操作 AQS 已经实现好了。

ReentrantLock

state 的初始值是 0,即没有被锁定,当 A 线程 tryAcquire() 时会独占锁住 state,并且把 state+1,然后 B 线程(即其他线程)tryAcquire() 时就会失败进入等待队列,直到 A 线程 tryRelease() 释放锁把 state-1,此时也有可能出现重入锁的情况,state-1 后的值不是 0 而是一个正整数,因为重入锁也会 state+1,只有当 state=0 时,才代表其他线程可以 tryAcquire() 获取锁。

CountDownLatch

8 人赛跑场景,即开启 8 个线程进行赛跑,state 的初始值设置为 8(必须与线程数一致),每个参赛者跑到终点(即线程执行完毕)则调用 countDown(),使用 CAS 操作把 state-1,直到 8 个参赛者都跑到终点了(即 state=0),此时调用 await() 判断 state 是否为 0,如果是 0 则不阻塞继续执行后面的代码。

作者:码农清风 链接:juejin.cn/post/692197…

四 volatile关键字的作用以及原理

4.1 volatile的第一个特性--保证可见性

解决内存可见性问题方式的一种是加锁,但是使用锁太笨重,因为它会带来线程上下文的切换开销。Java提供了一种弱形式的同步,也就是volatile关键字。该关键字确保对一个变量的更新对其他线程马上可见。

当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。

当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。

4.2 volatile的第二个特性--保证有序性

Java内存模型允许编译器和处理器对指令重排序以提高运行性能,并且只会对不存在数据依赖性的指令重排序。

什么是数据依赖性?
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

在单线程下重排序可以保证最终执行结果与程序顺序执行的结果一致,但是在多线程下就会出现问题。

为了禁止指令重排序,我们可以使用volatile修饰initialized变量。

4.3 volatile保证有序性和可见性的原理--内存屏障

前面介绍了volatile关键字的两大特性,为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

什么是内存屏障?

维基百科中对内存屏障的描述如下:

内存屏障(英语:Memory barrier),也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,它使得 CPU 或编译器在对内存进行操作的时候, 严格按照一定的顺序来执行, 也就是说在memory barrier 之前的指令和memory barrier之后的指令不会由于系统优化等原因而导致乱序。
大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。
语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。\

更多学习链接:mp.weixin.qq.com/s/56qLAap98…

五 synchronized 关键字原理分析

5.1 synchronized基础使用

  • synchronized 同步代码块指定加锁对象:对给定对象加锁,进入同步代码块前要获得给定对象的锁。

  • synchronized 修饰一个static静态方法时:对当前类对象加锁,访问方法时要获得当前类对象的锁。

  • synchronized 修饰一个非static静态方法时:对当前实例加锁,访问方法时要获得当前实例对象的锁。

5.2 synchronized原理

Synchronized 是JVM 实现的互斥同步锁,被 Synchronized 修饰过的代码块编译后的字节码,会生成monitorenter 和 monitorexit 两个字节码指令,monitorenter 指令是在编译后插入到同步代码块的开始位置,而monitorexit指令是插入到方法结束处和异常处。

  这两个指令是什么意思呢?

  在虚拟机执行到 monitorenter 指令时,首先尝试获取对象的锁:如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,就把锁的计数器 +1;

  当执行到 monitorexit 指令时将锁计数器 -1;当计数器为 0 时,锁就被释放了。

  如果获取对象的锁失败了,那当前线程就要阻塞等待,直到对象的锁被另外一个线程释放为止。

Java 中 Synchronized 通过在对象头设置标记,达到了获取锁和释放锁的目的。

同步代码块是使用monitorenter和monitorexit指令实现的,任何java对象都有一个monitor与之关联,当一个monitor被持有后,对象就处于锁定状态。

锁的数据结构
图片

六 ReentrantLock的底层原理

ReentrantLock底层使用了CAS+AQS队列实现

CAS已经上面已经介绍并了解,重点再说一下AQS队列

  • AQS队列

AQS是一个用于构建锁和同步容器的框架。

AQS使用一个FIFO的队列(也叫CLH队列,是CLH锁的一种变形),表示排队等待锁的线程。队列头节点称作“哨兵节点”或者“哑节点”,它不与任何线程关联。其他的节点与等待线程关联,每个节点维护一个等待状态waitStatus。结构如下图所示:

  • ReentrantLock的流程

ReentrantLock先通过CAS尝试获取锁

  • 如果此时锁已经被占用,该线程加入AQS队列并wait()
  • 当前驱线程的锁被释放,挂在CLH队列为首的线程就会被notify(),然后继续CAS尝试获取锁,此时:
    • 非公平锁,如果有其他线程尝试lock(),有可能被其他刚好申请锁的线程抢占
    • 公平锁,只有在CLH队列头的线程才可以获取锁,新来的线程只能插入到队尾。

(注:ReentrantLock默认是非公平锁,也可以指定为公平锁)

  • 总结

1.每一个ReentrantLock自身维护一个AQS队列记录申请锁的线程信息;

2.通过大量CAS保证多个线程竞争锁的时候的并发安全;

3.可重入的功能是通过维护state变量来记录重入次数实现的。

4.公平锁需要维护队列,通过AQS队列的先后顺序获取锁,缺点是会造成大量线程上下文切换;

5.非公平锁可以直接抢占,所以效率更高;

**ReentrantLock原理学习链接:**zhuanlan.zhihu.com/p/249147493

七 Synchronized 和 ReentrantLock 的实现原理有什么不同?

锁的实现原理基本都一致:让所有的线程都能看到某种标记

  Synchronized 通过在对象头设置标记实现这一目的,是一种 JVM 原生的锁实现方式,而 ReentrantLock 以及所有的基于 Lock 接口的实现类,都是通过一个 Volitile 修饰的 int 变量来设置标记实现这一目的,保证每个线程都能拥有对该 int 变量的可见性和原子修改。

  ReenTrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。因为线程自旋所以它的性能比较好,在用户态就把加锁的问题解决,避免了使线程进入内核态的阻塞状态。

  • 从几个方面来对比一下Synchronized和ReentrantLock的异同:

1 可重入性: 从名字上理解,ReenTrantLock的字面意思就是再进入的锁,其实synchronized关键字所使用的锁也是可重入的,两者关于这个的区别不大。两者都是同一个线程每进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

2 锁的实现: Synchronized是依赖于JVM实现的,而ReenTrantLock是JDK实现的,有什么区别,说白了就类似于操作系统来控制实现和用户自己敲代码实现的区别。前者的实现是比较难见到的,后者有直接的源码可供阅读。

3 性能的区别: 在Synchronized优化以前,synchronized的性能是比ReenTrantLock差很多的,但是自从Synchronized引入了偏向锁,轻量级锁(自旋锁)后,两者的性能就差不多了。在两种方法都可用的情况下,官方甚至建议使用synchronized,其实synchronized的优化我感觉就借鉴了ReenTrantLock中的CAS技术。都是试图在用户态就把加锁的问题解决,避免进入内核态的线程阻塞。

4 功能区别: 便利性:很明显Synchronized的使用比较方便简洁,并且由编译器去保证锁的加锁和释放,而ReenTrantLock需要手工声明来加锁和释放锁,为了避免忘记手工释放锁造成死锁,所以最好在finally中声明释放锁。锁的细粒度和灵活度:很明显ReenTrantLock优于Synchronized

5 ReenTrantLock独有的 能力:

5.1 ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。

5.2 ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像synchronized要么随机唤醒一个线程要么唤醒全部线程。

5.3 ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。

  • 什么情况下使用ReenTrantLock

答案是,如果你需要实现ReenTrantLock的三个独有功能时。