Java多线程(进阶) 在 之前的 Java多线程初阶中我们主要学习的是更贴合于工作实际情况的内容而在 本文 Java多线程进阶中我们主要学习的是贴合于面试中的内容。本文以下的内容主要是以理解与记忆为主接下来就让我们一起走入 Java多线陈进阶的学习吧 一.常见的锁策略1. 乐观锁 vs 悲观锁其中乐观和悲观并不是某一种具体的锁而是一个具体的锁具有 “乐观” 或 “悲观” 的特性。乐观加锁的时候预测接下来的锁竞争的情况非常激烈就会针对锁竞争的激烈情况额外做一些工作悲观加锁的时候预测接下来的锁竞争的情况不激烈就不会针对锁竞争不激烈的情况做额外的工作锁竞争激烈的情况有 20个线程同时尝试获取同一把锁每个线程的加锁频率都特别高当一个线程尝试进行加锁的时候这个锁大概率被其他线程占用着锁竞争不激烈的情况有 2个线程同时尝试获取同一把锁每个线程的加锁频率都特别低当一个线程进行加锁的时候这个锁大概率是空闲状态即没被其他线程所占用2. 重量级锁 vs 轻量级锁这两种锁策略是当锁遇到不同的锁竞争场景下乐观场景 和 悲观场景的解决方案。重量级锁在悲观场景下的应对策略此时就要付出更大的代价 更低效轻量级锁在乐观场景下的应对策略此时付出的代价就比较小 更高效3.挂起等待锁 vs 自旋锁这两种锁策略是面对上述两种解决方案重量级锁 和 轻量级锁的典型实现挂起等待锁重量级锁的典型实现属于操作系统级别在加锁遇到竞争时线程就会进入阻塞状态后续的唤醒就需要系统内核来唤醒自旋锁轻量级锁的典型实现属于应用程序级别在加锁遇到竞争时线程一般不会进入阻塞状态而是通过忙等的方式来进行等待在 Java多线程初阶中我们提到过忙等是一种很消耗 cpu 资源的行为但在乐观场景下加锁时遇到锁竞争的概率比较小就算真的不幸遇到了锁竞争线程也可以很快地就拿到锁。所以此处的忙等时间会比较短消耗的 cpu资源也相对较少这是可以接受的其中我们之前所学的 synchronized 锁 是一种自适应锁在 JVM 的内部会帮我们统计好各个锁的竞争激烈程度当竞争激烈时synchronized 就会按照轻量级锁自旋的方式进行加锁当竞争不激烈时synchronized 就会按照重量级锁挂起等待的方式进行加锁。4. 普通互斥锁 vs 读写锁普通互斥锁synchronized的行为分为两种加锁 与 解锁 而读写锁则将加锁操作进行了进一步的细分其行为分为三种以读方式加锁、以写方式加锁 和 解锁。在 Java多线程初阶中我们知道当多个线程同时读取同一个数据时是不会引发线程安全问题的因为读操作本身就是线程安全的 只有涉及了写操作才会引发线程安全问题。也就是说当我们遇到以下这种情况大部分操作都是读操作小部分操作是写操作时。如果你在读操作和写操作都加上了普通的互斥锁这时的锁冲突就会非常严重会严重地影响性能。这时我们就可以运用读写锁来解决问题。读写锁可以确保 读锁 与 读锁 之间不是互斥的即两个读锁之间不会产生阻塞只有 读锁 与 写锁 或 写锁 与 写锁 之间会产生互斥发生阻塞。像这样我们就可以在保证线程安全的前提下降低锁冲突的概率提高性能。5. 可重入锁 vs 不可重入锁在之前的 Java多线程初阶中我们知道synchronized 是 “可重入锁”即一个线程多次加同一个锁多次并不会产生死锁问题。不可重入锁则相反即在一个线程多次加同一把锁的情况下会产生死锁问题。可重入锁的实现要点1.锁要记录下当前是哪个线程拿到了这把锁2.使用计数器记录下该线程进行了多少次的重复加锁并在合适的时候进行解锁6. 公平锁 vs 非公平锁当一个线程释放所持有的锁之后这个锁应该以什么样的方式被后面竞争的线程获取呢现在有以下两种方式1.按照先来后到的方式哪个线程先竞争这把锁那么就由哪个线程拿到这把锁2.以概率均等的方式随机决定由其中的一个线程拿到锁以第一种方式实现的锁就是公平锁反之以第二种方式实现的所就是非公平锁。我们之前所学的synchronized 就是非公平锁。7.锁升级锁升级的方向无锁 偏向锁 轻量级锁自旋锁 重量级锁挂起等待锁其中偏向锁的定义就是在 进行 synchronized 的初期时并不是真的进行了加锁操作而是简单做了一个标记这个标记非常轻量相比于加锁解锁来说效率比较高。在该线程运行的过程当中如果没有其他线程来竞争这把锁那么当该线程执行到解锁代码时也只需要简单清除上述标记即可像这样该线程的运行过程中就不会涉及到加锁和解锁操作效率就会比较高。而当有别的线程来竞争这把锁时该线程就会抢先一步拿到这把锁并进行真正的加锁操作也就是从偏向锁升级到了轻量级锁。锁升级时的条件无锁 偏向锁 代码进入 synchronized 代码块当中偏向锁 轻量级锁 拿到偏向锁的线程运行过程中有别的线程尝试竞争这把锁轻量级锁 重量级锁 JVM 发现这把锁的竞争非常激烈时在 JVM 当中只提供了 锁升级 而不能 锁降级8. 锁消除锁消除是编译器优化的一种体现在代码编译的过程当中编译器会判定这个代码逻辑是否真正需要加锁如果真的不需要加锁但是在该代码中又有 synchronized 时编译器就会自动把 synchronized 去掉编译器进行 锁消除操作 时比较保守只有当百分之百确定代码不需要加锁操作时才会触发锁消除操作判定不清楚的情况则不会触发。9. 锁粗化在介绍锁粗化之前我们先了解一下锁的粒度。锁的粒度在加锁与解锁之间所包含的代码越多锁的粒度就越粗所包含的代码越少锁的粒度就越细。代码的多少并不是指代码的行数而是代码实际执行的指令和时间。所谓锁的粗化就是将粒度较细的锁优化为粒度较粗的锁。当代码中针对三个不同的逻辑都加了不同的三个锁时我们就可以将这三个逻辑都放入同一个锁中像这样我们将三个粒度较细的锁优化为了一个粒度较粗的锁。注锁的粒度并不是越粗越好而是要视情况而定。锁越粗就会影响到线程的并发程度。如果确实这三件事本身都需要加锁那么优化为一个粒度较粗的锁是合理的。但是如果三件事中只有两件事需要加锁却被优化为了一把锁就会导致原本可以并行执行的那件事也变成串行执行了这样就会影响线程的并发程度。二. CASCAS 的全称为 compare and swap意为比较与交换一个CAS操作主要包括以下三步下面我们假设内存中的原数据V旧的预期值A需要修改的新值B1. 比较A与V是否相等比较 2. 如果比较相等将B写入V交换 3. 返回操作是否成功CAS 本质上是 CPU 的指令具有原子性操作系统会将这个指令进行封装并提供一些 api 应用程序编程接口所以 CAS 就可以在 C 中使用了而由于 JVM 又是又 C 实现的所以JVM 也能够使用这些 apiCAS 最主要的功能就是实现了原子类比如对 boolean、int、long 等类型进行了封装。像在 Java多线程初阶的内容中count 操作因为不是原子的所以是不安全的需要进行加锁让其变成原子的来解决多线程问题。但是我们又知道加锁的效率是比较低的于是我们就可以通过 CAS 来实现 count在确保性能的同时又保证了线程安全。使用原子类的目的就是为了避免加锁。CAS 的一个典型缺陷ABA问题使用 CAS 实现线程安全的编程的核心步骤是比较是否 “相等”即内存和寄存器中的值是否相等。如果相等那么就认为在这个线程执行操作的过程中没有其他线程对这个值进行修改因此接下来的修改操作就是线程安全的那么就可以将新值写入内存中。但是实际上可能存在以下的这种情况另一个线程将内存中的 A 改为了 B然后又将 B 改为了 A。那么在第一个线程看来内存和寄存器中的值是相等的也就会认为接下来的修改操作是线程安全的并将新值写入内存中这样就可能会引发线程安全问题这个问题也称作 ABA 问题。接下来我们就来举一个例子来说明这个问题。就比如说取钱。我们在用手机取钱的时候在取款的页面点击取款时由于卡了一下我们就连续点击了多次取款键此时就可能出现了两个线程线程1 和 线程2来并发执行扣款操作。接下来我们就按照 CAS 的方式来执行扣款操作。假设我们的账户初始余额为1000。即最开始时线程1 和 线程2 的寄存器中保存的余额值 和内存中保存的余额值如下图所示当我们进行取款操作时由于此时 寄存器 和 内存 中的值相等线程1 可以将内存中的值进行修改即取款操作假设将该值修改为500此时线程2 的寄存器中的余额值 和 内存中的余额值如下所示此时若线程2 想要修改内存中的值由于寄存器中的值并不等于内存中的值CAS 修改操作就会失败也就保证了CAS操作的线程安全。但是上述的情景只是最普通的情况在这些普通情况下确实不会发生线程安全问题。那么要是在极端情况下呢以下就是 CAS 中由于极端情况引起的 ABA问题一种情景。前置条件不变还是有两个线程线程1 和 线程2来同时进行一个取款操作与之前不同的是这次多了一个执行存款操作的线程3。那么当线程1 执行完取款操作的同时线程3 同步执行完一次存款操作存取款的金额相同。那么各个线程的寄存器中的值和内存中的值的变化就会如下所示此时我们发现在经过 线程1 的取款操作 和 线程3 的存款操作之后线程2 的 寄存器的值 和 内存中保存的值 又恰巧相等了。那么当线程2 执行取款操作时就会认为这次操作是线程安全的也就可以通过 CAS 操作 继续修改内存中的值为500。但是我们知道取款操作在线程1就已经执行成功了线程2 这次执行属于是 重复执行这也就是 CAS 中的 ABA 问题解决 ABA 问题的方法在上述问题中我们是使用余额的数值来判定中间是否有线程穿插修改过余额的值但是余额的值是可以加也可以减的这样就有可能导致 ABA 问题因为在其他线程的穿插修改后该数值可能和未穿插修改的值恰好一致。为了解决这个问题我们可以引入版本号的概念版本号只能加不能减。当有线程修改数据时版本号就会 1 我们就可以通过版本号的数值来判定中间是否有线程穿插修改过余额的值。像这样就算最后修改过的余额的值和未修改过的余额的值恰好相同但版本号是绝对不可能相等的也就是说本次 CAS 操作 就会失败也就杜绝了发生 ABA 问题的可能。三. ReentrantLock 可重入锁ReentrantLock 和 synchronized 是 并列的关系都是可重入锁但二者又略有不同。ReentrantLock 和 synchronized 之间的区别1. synchronized 是关键字内部是 JVM 通过 C 实现的而ReentrantLock 是 Java 标准库中提供的一个类2. synchronized 是通过代码块来控制加锁和解锁ReentrantLock 则需要使用 lock/unlock 方法来控制加锁和解锁使用时需注意 unlock方法不被调用的问题3.ReentrantLock 除了提供 lock 和 unlock 方法之外 还提供了一个 方法 tryLocktryLock方法不会阻塞加锁成功返回 true 返回失败返回 false调用者可以通过对返回值的判定来决定下一步要怎么做。可以设置超时时间在等待时间达到超时时间后再返回 true 或 false。4. ReentrantLock 提供了公平锁的实现默认是非公平锁5.ReentrantLock 搭配的等待通知机制是 Condition类相比于 wait/notify功能更加强大四. 信号量 Semaphore信号量本质上是一个 计数器 阻塞队列用来控制多线程对共享资源的访问数量能协调多个线程之间的资源分配。信号量表示的是 “可用资源的个数”当我们申请一个资源时计数器就会 -1P操作释放一个资源计数器就会 1V操作。当计数器为 0 时继续申请就会阻塞等待。二元信号量信号量的一种特殊情况初始值为1计数器取值要么是1要么是0。等价于 “锁”。如果是 普通的初始值为 N 的信号量就可以限制同时有多少个线程来执行该逻辑。五. CountDownLatch当我们使用多线程时常常将一个大的任务拆分为多个子任务然后再使用多线程来执行这些子任务从而提高程序的效率。但是我们又应该如何去衡量这些子任务都完成了呢整个任务完成了呢CountDownLatch是 JUC 下的线程同步工具底层基于 AQS内部持有一个计数器作用一个或多个线程等待其他 N 个线程全部完成任务后再继续执行使用流程如下1. 在构造方法中传入参数表示该大任务被拆分成了多少个子任务N个要等待的线程数量2. 在每个线程完成一个子任务后调用 countDown 方法N - 13. 主线程中调用 await 方法等待所有的子任务执行完毕后等待结束主线程继续执行剩余逻辑六. 多线程下使用 ArrayList在多线程的条件下我们要想在线程安全的前提下使用 ArrayList有以下三种方式1. 在相关逻辑中自行加锁 推荐使用2.使用 Collections synchronizedListnew ArrayList创建一个线程安全的 ArrayList。该方法返回的 List 的各种关键方法public 修饰的方法中都是带有 synchronized 修饰的所以保证了线程安全但是也由于加了太多的锁而导致效率降低不推荐3. 使用 CopyOnWriteArrayList 写时拷贝这个方法并不需要加锁当有线程对数组进行读取数据操作时一旦有其他线程进行了写操作就可能会出现线程安全问题比如说读到了修改了一半的数据等。而写时拷贝则是在有线程进行写操作时如果有其他线程进行读操作那么就直接读取就版本的数据。像这样虽然有线程在进行写操作但由于提供了旧版本的数据不会影响到其他线程的读操作保证了数据读取的完整性即要么读到的是旧版数据要么读到的是新版数据并不会出现读到仅修改到一半的脏数据。保存旧版数据的方式每次写操作都会对原数据进行一次拷贝即重新创建一个新数组用于存放新版数据在整个写操作完成之后再让指向旧版数据的引用指向新版数据。这整个过程中并不涉及加锁操作不会产生阻塞。但是上述方案也有明显的缺点1. 当数组特别大时写时拷贝的方案特别低效 复制需要消耗大量时间2. 当有多个线程同时进行写操作时也容易出现线程安全问题比如多个线程同时执行自增操作时由于线程1 和 线程2 是同时进行修改操作所以它们读取到的原数组中值是一样的那么当他们分别在自己复制出的数组进行自增操作时就会导致两次自增操作结果的覆盖。即得到的新数组的值是原数组只进行了一次自增操作的值但实际上原数组是进行了两次自增操作七. 多线程下使用哈希表在之前的数据结构中的学习中我们知道HashMap 是线程不安全的而我们还学习了一种线程安全的哈希表 HashTable给方法中各种 public 方法都加了 synchronized锁对象为一整个哈希表对象。但我们知道HashTable 会因为加了太多的锁而导致效率降低。相比之下我们更推荐使用 ConcurrentHashMap 由于其是按照 “桶” 级别进行加锁而不是给整个哈希加一个全局锁这就有效地降低了锁冲突的概率也避免了加大量锁而导致的效率降低问题。给每个哈希桶加锁的示意图如下像这样我们对每个哈希桶分别加不同的锁只有在多个线程同时修改同一个哈希桶中的数据时才会发生阻塞等待。而多个线程同时修改不同哈希桶中的数据时本身就是线程安全的所以无需进行加锁进行阻塞等待。这样有目的地针对哈希表的特性进行加锁的 ConcurrentHashMap 就在线程安全的基础上使该哈希表的效率尽可能地高。而在维护 size 这个属性时ConcurrentHashMap 则使用了原子类来保证线程安全当 ConcurrentHashMap 要进行扩容操作时并不会一下子将所有旧的哈希表中的旧元素都搬运到新的哈希表中因为有时哈希表会非常大元素很多一下子全都搬运会消耗很多时间而是采用了化整为零的方法将一整个搬运任务分为多次来完成在触发 “扩容”之后只先搬运一小部分后续再进行基本操作时每次都会再次触发一次搬运直到搬运完成为止。ConcurrentHashMap 的核心优化点总结1. 将 锁整个哈希表 优化为 锁哈希表中的每一个锁2.使用原子类对 size 进行维护3.针对哈希扩容时采用 “化整为零” 的方式确保每一个操作加锁的时间不要太长以上就是本片文章的全部要点欢迎各位读者阅读与指正