在 Java 多线程中, 可以使用 synchronized 关键字来实现线程之间同步互斥, 在 JDK1.5 以后, Java 类库中新增了 Lock 接口用来实现类似的锁功能下面会逐一介绍关于 Java 类库中所提供的锁功能
锁可以理解为对共享数据进行保护的许可证, 对于同一把锁保护的共享数据而言, 任何线程对这些共享数据的访问都需要先持有该锁一把锁只能同时被一个线程持有, 当以一个该锁的持有线程对共享数据访问结束之后必须释放该锁, 以便让其他线程持有锁的持有线程在锁的获得和锁的释放之间的这段时间所执行的代码被称为临界区
锁能够保护共享数据以实现线程安全, 锁的主要作用有保障原子性保障可见性和保障有序性由于锁具有互斥性, 因此当线程执行临界区中的代码时, 其他线程无法做到干扰, 临界区中的代码也就具有了不可分割的原子特性
锁具有排他性, 即一个锁一次只能被一个线程持有, 这种锁又被称之为排他锁或互斥锁当然, 新版本的 JDK 中为了性能优化还推出了另一种锁读写锁, 读写锁是作为了排它锁的一种改进而存在的
按照 Java 虚拟机对锁的实现方式划分, Java 平台中的锁包括内部锁 (主要是通过 synchronized 实现) 和显式锁(主要是通过 Lock 接口及其实现类实现), 下文将逐一介绍
公平锁和非公平锁:
锁 Lock 分为 "公平锁" 和 "非公平锁", 公平锁表示线程获取锁的顺序是按照线程加锁的顺序来分配的, 即先来先得的 FIFO 先进先出顺序非公平锁就是一种获取锁的抢占机制, 是随机获得锁的, 和公平锁不一样的就是先来的不一定先得到锁, 这个方式可能造成某些线程一直拿不到锁, 结果也就是不公平的了
内部锁属于非公平锁, 而显式锁不仅支持公平锁而且支持非公平锁
内部锁众所周知的 synchronized
Java 平台中的任何一个对象都有唯一一个与之关联的锁, 这种锁被称之为监视器 (或者叫内部锁) 内部锁是一种排它锁, 它能保证原子性可见性和有序性内部锁就由 synchronized 关键字实现
synchronized 可以修饰方法或者代码块当 synchronized 修饰方法的时候, 该方法内部的代码就属于一个临界区, 该方法就属于一个同步方法此时一个线程对该方法内部的变量的更新就保证了原子性和可见性, 从而实现了线程安全当 synchronized 修饰代码块的时候, 需要一个锁句柄(一个对象的引用或者是一个可以返回对象的表达式), 此时 synchronized 关键字引导的代码块就是临界区; 同步块的锁句柄可以写为 this 关键字, 此时表示为当前对象, 锁句柄对应的监视器就被称之为相应同步块的引导锁
作为锁句柄的变量通常以 private final 修饰, 防止锁句柄变量的值改变之后, 导致执行同一个同步块的多个线程使用不同的锁, 从而避免了竞态
同步实例方法相当于以 "this" 为引导锁的同步块; 同步静态方法相当于以当前类对象为引导锁的同步块
线程读内部锁的申请和释放均由 Java 虚拟机负责代为实施, 内部锁的使用不会导致锁泄漏, 这是因为 Java 编译器在将同步块代码编译成字节码的时候, 对临界区中可能抛出的而程序代码中又未捕获的异常进行了特殊的处理, 这使得临界区的代码即使抛出异常也不会妨碍内部锁的释放
注意 Java 虚拟机会为每一个内部锁分配一个入口集用于存放等待获得相应内部锁的线程, 当内部锁的持有线程释放当前锁的时候, 可能是入口集中处于 BLOCKED 状态的线程获得当前锁也可能是处于 RUNNABLE 状态的其他线程内部锁的竞争是激烈的, 也是不公平的, 可能等待了长时间的线程没有获得锁, 也可能是没有经过等待的线程直接就获得了锁
显式的加锁和解锁 Lock 接口
在 Java5.0 之前, 在协调对共享对象的访问时可以使用的机制只有 synchronized 和 volatile 在 Java 5.0 中增加了一种新的机制: Lock 接口(以及其实现类如 ReentrantLock 等),Lock 接口中定义了一组抽象的加锁操作与 synchronized 不同的是, synchronized 可以方便的隐式的获取锁, 而 Lock 接口则提供了一种显式获取锁的特性
显式锁是自从 JDK1.5 之后开始引入的排它锁显式锁是 Lock 接口的实例, Lock 接口的默认实现类是 ReentrantLock
重入锁 ReentrantLock 类
在详细介绍关于 ReentrantLock 类的详细信息之前, 先介绍一下锁的可重入性的概念
如果一个线程持有一个锁的时候还能继续成功的申请该锁, 那么我们就称该锁是可重入的, 否则我们就称该锁是非可重入的
ReentrantLock 是一个可重入锁, ReentrantLock 类与 synchronized 类似, 都可以实现线程之间的同步互斥但 ReentrantLock 类此外还扩展了更多的功能, 如嗅探锁定多路分支通知等, 在使用上也比 synrhronized 更加的灵活
上面已经提到 ReentrantLock 是一个既支持公平支持非公平的显示锁, 所以在实例化 ReentrantLock 类的时候我们可以明确的看到 ReentrantLock 的一个构造签名为
ReentrantLock(boolean fair)
, 当我们传入 true 的时候得到的锁是一个公平锁公平锁的开销较非公平锁的开销大, 因此显式锁默认使用的是非公平的调度策略由于 ReentrantLock 可以具有公平性, 因此:
默认情况下使用内部锁, 而当多数线程持有一个锁的时间相对较长或者线程申请锁的平均时间间隔相对长的情况下我们可以考虑使用显式锁
读写锁(Read/Write Lock)
读写锁是一种改进型的排它锁读写锁允许多个线程可以同时读取 (只读) 共享变量读写锁是分为读锁和写锁两种角色的, 读线程在访问共享变量的时候必须持有相应读写锁的读锁, 而且读锁是共享的多个线程可以共同持有的; 写锁是排他的, 以一个线程在持有写锁的时候, 其他线程无法获得相应锁的写锁或读锁总之, 读写锁通过读写锁的分离从而提高了并发性
ReadWriteLock 接口是对读写锁的抽象, 其默认的实现类是 ReentrantReadWriteLockReadWriteLock 定义了两个方法 readLock()和 writeLock(), 分别用于返回相应读写锁实例的读锁和写锁这两个方法的返回值类型都是 Lock
读写锁主要用于读线程持有锁的时间比较长的情景下
锁的替代
多个线程共享同一个非线程安全对象时, 我们往往采用锁来保证线程安全性但是, 锁也有其弊端, 比如锁的开销和在使用锁的时候容易发生死锁等所以在 Java 中也提供了一些对于某些情况下替代锁的同步机制解决方案, 如 volatile 关键字 final 关键字 static 关键字原子变量以及各种并发容器和框架, 这些大多数内容我将以后介绍; 此外我们还可以采用一定的多线程设计模式来完成多线程的同步
首先介绍在并发程序设计中, 我们使用和共享对象可以采用的一些策略上面所提到的 Java 内置的一些工具类和关键字以及我们所采用的设计模式大多都基于这些策略的思想
采用线程特有对象: 各个不同的线程创建各自的实例, 一个实例只能被一个线程访问的对象就被称之为线程的特有对象采用线程特有对象, 保障了对非线程安全对象的访问的线程安全
只读共享: 在没有额外同步的情况下, 共享的只读对象可以有可以由多个线程并发访问, 但是任何线程都不能修改它共享的只读对象包括不可变对象和事实不可变对象
线程安全共享: 线程安全的对象在其内部实现同步, 多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步
保护对象: 被保护的对象只能通过持有特定的锁来访问保护对象包括封装在其他线程安全对象中的对象, 以及已发布的并且由某个特定锁保护的对象
这里我首先介绍其中的 volatile 关键字 ThreadLocal 二者在锁的某些功能上的替代作用
volatile 关键字
通过 volatile 关键字的使用, 我们可以保证共享变量的可见性和有序性不同于常见的锁, 在原子性方面, volatile 仅能保障写 volatile 变量操作的原子性, 没有锁的排他性; 此外, volatile 关键字的使用不会引起上下文的切换, 因此 volatile 常被称为轻量级锁
在多线程编程基础一文中, 我已经初步介绍了 Java 的内存模型 volatile 最主要的就是实现了共享变量的内存可见性, 其实现的原理是: volatile 变量的值每次都会从高速缓存或者主内存中读取, 对于 volatile 变量, 每一个线程不再会有一个副本变量, 所有线程对 volatile 变量的操作都是对同一个变量的操作
volatile 变量的开销包括读变量和写变量两个方面 volatile 变量的读写操作都不会导致上下文的切换, 因此 volatile 的开销比锁小但是 volatile 变量的值不会暂存在寄存器中, 因此读取 volatile 变量的成本要比读取普通变量的成本更高
ThreadLocal
ThreadLocal, 即线程变量, 是一个以 ThreadLocal 对象为键任意对象为值的存储结构这个结构被附带在线程上, 也就是说一个线程可以根据一个 ThreadLocal 对象查询到绑定在这个线程上的一个值
ThreadLocal 采用的是上述策略中的第一种设计思想采用线程的特有对象. 采用线程的特有对象, 我们可以保障每一个线程都具有各自的实例, 同一个对象不会被多个线程共享, ThreadLocal 是维护线程封闭性的一种更加规范的方法, 这个类能使线程中的某个值与保存值的对象关联起来, 从而保证了线程特有对象的固有线程安全性
ThreadLocal 类相当于线程访问其线程特有对象的代理, 即各个线程通过这个对象可以创建并访问各自的线程特有对象, 泛型 T 指定了相应线程持有对象的类型一个线程可以使用不同的 ThreadLocal 实例来创建并访问其不同的线程持有对象多个线程使用同一个 ThreadLocal 实例所访问到的对象时类型 T 的不同实例代理的关系图如下:
ThreadLocal 提供了 get 和 set 等访问接口或方法, 这些方法为每一个使用该变量的线程都存有一份独立的副本, 因此 get 总是能返回由当前执行线程在调用 set 时设置的最新值其主要使用的方法如下:
public T get(): 获取与当前线程中 ThreadLocal 实例关联的线程特有对象
public void set(T value): 重新关联当前线程中 ThreadLocal 实例所对应的线程特有对象
protected T initValue(): 如果没有调用 set(), 在初始化 threadlocal 对象的时候, 该方法的返回值就是当前线程中与 ThreadLocal 实例关联的线程特有对象
public void remove(): 删除当前线程中 ThreadLocal 和线程特有对象的关系
那么 ThreadLocal 底层是如何实现 Thread 持有自己的线程特有对象的? 查看 set()方法的源代码:
可以看到, 当我们调用 threadlocal 的 set 方法来保存当前线程的特有对象时, threadlocal 会取出当前线程关联的 threadlocalmap 对象, 然后调用 ThreadLocalMap 对象的 set 方法来进行当前给定值的保存
每一个 Thread 都会维护一个 ThreadLocalMap 对象, ThreadLocalMap 是一个类似 Map 的数据结构, 但是它没有实现任何 Map 的相关接口 ThreadLocalMap 是一个 Entry 数组, 每一个 Entry 对象都是一个 "key-value" 结构, 而且 Entry 对象的 key 永远都是 ThreadLocal 对象当我们调用 ThreadLocal 的 set 方法时, 实际上就是以当前 ThreadLocal 对象本身作为 key, 放入到了 ThreadLocalMap 中
可能发生内存泄漏:
通过查看 Entry 结构可知, Entry 属于 WeakReference 类型, 因此 Entry 不会阻止被引用的 ThreadLocal 实例被垃圾回收当一个 ThreadLocal 实例没有对其可达的强引用时, 这个实例就可以被垃圾回收, 即其所在的 Entry 的 key 会被置为 null, 但是如果创建 ThreadLocal 的线程一直持续运行, 那么这个 Entry 对象中的 value 就有可能一直得不到回收, 从而发生内存泄露
解决内存泄漏的最有效方法就是, 在使用完 ThreadLocal 之后, 要注意调用 threadlocal 的 remove()方法释放内存
来源: https://juejin.im/post/5a9f930d51882555635dd7dd