多线程:从线程安全到锁机制
多线程:从线程安全到锁机制
本文详细介绍了Java多线程中的锁机制,从乐观锁、CAS到synchronized和ReentrantLock的底层实现,再到ThreadLocal的原理和使用场景,内容详实且深入浅出,适合Java开发者深入理解多线程编程中的锁机制。
线程安全
- 乐观锁,CAS思想
- synchronized 底层实现
- ReentrantLock 底层实现
- 公平锁和非公平锁区别
- 使用层面锁优化
- 系统层面锁优化
- ThreadLocal 原理
- HashMap 线程安全
- String 不可变原因
1. 乐观锁,CAS思想
1.1 Java 乐观锁机制
乐观锁体现的是悲观锁的反面。它是一种积极的思想,它总是认为数据是不会被修改的,所以是不会对数据上锁的。但是乐观锁在更新的时候会去判断数据是否被更新过。乐观锁的实现方案一般有两种(版本号机制和CAS)。乐观锁适用于读多写少的场景,这样可以提高系统的并发量。在 Java 中
java.util.concurrent.atomic
下的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。
乐观锁,大多是基于数据版本(
Version
)记录机制实现。即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个
“version”
字段来实现。读取数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。
1.2 CAS 思想
CAS 就是
compare and swap
(比较交换),是一种很出名的无锁的算法,就是可以不使用锁机制实现线程间的同步。使用
CAS
线程是不会被阻塞的,所以又称为非阻塞同步。
CAS
算法涉及到三个操作:
- 需要读写内存值 V;
- 进行比较的值 A;
- 准备写入的值 B;
当且仅当 V 的值等于 A 的值时候,才用 B 的值取更新 V 的值,否则不会执行任何操作(比较和替换是一个原子操作-A和V比较,V和B替换),一般情况下是一个自旋操作,即不断重试**。
缺点:
- ABA 问题,如果另外一个线程修改 V 值,假设原来是 A,先修改成 B,再修改回成 A。当前线程的 CAS 操作无法分辨当前 V 值是否发生过变化。
- 高并发情况下,很容易发生并发冲突,如果 CAS 一直失败,那么就会一直重试,浪费 CPU 资源。
1.3 原子性
功能限制 CAS 是能保证单个变量的操作是原子性的,在 Java 中要配合使用
volatile
关键字来保证线程的安全;当涉及到多个变量的时候 CAS 无能为力;除此之外 CAS 实现需要硬件层面的支持,在 Java 的普通用户中无法直接使用,只能借助
atomic
包下的原子类实现**,灵活性受到了限制。
2. synchronized 底层实现
2.1 使用方法:主要的三种使用方式
- 修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
- 修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员。
- 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁。
总结:
synchronized
锁住的资源只有两类:一个是对象,一个是类。
2.2 底层实现
对象头是我们需要关注的重点,它是
synchronized
实现锁的基础,因为 synchronized 申请锁、上锁、释放锁都与对象头有关。对象头主要结构是由
Mark Word
组成,其中
Mark Word
存储对象的
hashCode
、锁信息或分代年龄或 GC 标志等信息。
锁也分不同状态,JDK6 之前只有两个状态:无锁、有锁(重量级锁),而在 JDK 6 之后对
synchronized
进行了优化,新增了两种状态,总共就是四个状态:无锁状态、偏向锁、轻量级锁、重量级锁,其中无锁就是一种状态了。锁的类型和状态在对象头
Mark Word
中都有记录,在申请锁、锁升级等过程中
JVM
都需要读取对象的
Mark Word
数据。
同步代码块是利用
monitor enter
和
monitor exit
指令实现的,而同步方法则是利用
flags
实现的。
3. ReentrantLock 底层实现
由于
ReentrantLock
是**
java.util.concurrent
包下提供的一套互斥锁,相比**
Synchronized
,
ReentrantLock
类提供了一些高级功能。
3.1 使用方法
基于 API 层面的互斥锁,需要
lock()
和
unlock()
方法配合
try/finally
语句块来完成
3.2 底层实现
ReenTrantLock
的实现是一种自旋锁,通过循环调用
CAS
操作来实现锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。
3.3 和 synchronized 区别
- 底层实现:synchronized 是 JVM 层面的锁,是 Java 关键字,通过 monitor 对象来完成(
monitor enter
与
monitor exit
),ReentrantLock 是从 JDK1.5 以来(
java.util.concurrent.locks.Lock
)提供的 API 层面的锁。
2. 实现原理:synchronized 的实现涉及到锁的升级,具体为无锁、偏向锁、自旋锁、向 OS 申请重量级锁;ReentrantLock 实现则是通过利用
CAS(CompareAndSwap)
自旋锁机制保证线程操作的原子性和
volatile
保证数据可见性以实现锁的功能。
3. 是否可手动释放锁:synchronized 不需要用户去手动释放锁,synchronized 代码执行完后系统会自动让线程释放对锁的占用;
ReentrantLock
则需要用户去手动释放锁,如果没有手动释放锁,就可能导致死锁现象。
4. 是否可中断:
synchronized
是不可中断类型的锁,除非加锁的代码中出现异常或正常执行完成;
ReentrantLock
则可以中断,可通过
trylock(long timeout, TimeUnit unit)
设置超时方法或者将
lockInterruptibly()
放到代码块中,调用
interrupt
方法进行中断。
5. 是否公平锁:
synchronized
为非公平锁
ReentrantLock
则即可以选公平锁也可以选非公平锁,通过构造方法
new ReentrantLock
时传入
boolean
值进行选择,为空默认
false
非公平锁,
true
为公平锁,公平锁性能非常低。
4. 公平锁和非公平锁区别
4.1 公平锁
公平锁自然是遵循
FIFO(先进先出)
原则的,先到的线程会优先获取资源,后到的会进行排队等待。
- 优点:所有线程都能得到资源,不会饿死在队列中,适合大任务。
- 缺点:吞吐量会下降,队列里面除了第一个线程,其他的线程都会阻塞,cpu 唤醒阻塞线程的开销大。
4.2 非公平锁
多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。
- 优点:可以减少 CPU 唤醒线程的开销,整体的吞吐效率会高点,CPU 也不必唤醒所有线程,会减少唤起线程的数量。
- 缺点:可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁。
4.3 公平锁效率低原因
公平锁要维护一个队列,后来的线程要加锁,即使锁空闲,也要先检查有没有其他线程在
wait
,如果有自己要挂起,加到队列后面,然后唤醒队列最前面线程。这种情况下相比较非公平所多了一次挂起和唤醒。
线程切换的开销,其实就是非公平锁效率高于公平锁的原因,因为非公平锁减少了线程挂起的几率,后来的线程有一定几率逃离被挂起的开销。
5. 使用层面锁优化
- 减少锁的时间:
不需要同步执行的代码,能不放在同步快里面执行就不要放在同步块内,可以让锁尽快释放;
2. 减少锁的粒度:
它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间;
java
中很多数据结构都是采用这种方法提高并发操作的效率,比如:
- ConcurrentHashMap:
java 中的
ConcurrentHashMap
在 jdk1.8 之前的版本,使用一个
Segment< K, V> [] segments
Segment
继承自
ReentrantLock
,所以每个
Segment
是个可重入锁,每个
Segment
有一个
HashEntry< K, V >
数组来存放数据,
put
数据时,先确定往哪个
Segment
放数据,只需要锁定这个
Segment
,执行
put
, 其它的
Segment
不会被锁定;所以数组中有多少个
Segment
就允许同一时刻多少个线程存放数据,这样增加了并发能力。
- 锁粗化:
大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度;假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循坏外面,否则每次进出循环,都进出一次临界区,效率是非常差的;
4. 使用读写锁:
ReentrantReadWriteLock
是一个读写锁,读操作加读锁,可并发读,写操作使用写锁,只能单线程写;
5. 使用CAS:
如果需要同步的操作执行速度非常快,并且线程竞争并不激烈,这时候使用 CAS 效率会更高,因为加锁会导致线程的上下文切换,如果上下文切换的耗时比同步操作本身更耗时,且线程对资源的竞争不激烈,使用
volatiled + CAS
操作会是非常高效的选择;
6. 系统层面锁优化
6.1 自适应自旋锁
自旋锁可以避免等待竞争锁进入阻塞挂起状态被唤醒造成的内核态和用户态之间的切换的损耗,它们只需要等一等(自旋),但是如果锁被其他线程长时间占用,一直不释放 CPU,死等会带来更多的性能开销;自旋次数默认值是 10。
对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。
6.2 锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
Netty
中无锁化设计
pipeline
中
channelhandler
会进行锁消除的优化。
6.3 锁升级
- 偏向锁:
如果线程已经占有这个锁,当他再次试图去获取这个锁的时候,它会以最快的方式去拿到这个锁,而不需要再进行一些
monitor
操作,因为在大部分情况下时没有竞争的,所以使用偏向锁是可以提高性能的;
2. 轻量级锁:
在竞争不激烈的情况下,通过
CAS
避免线程上下文切换,可以显著的提高性能;
3. 重量级锁:
重量级锁的加锁、解锁过程造成的损耗是固定的,重量级锁适合于竞争激烈、高并发、同步块执行时间长的情况。
7. ThreadLocal 原理
7.1 ThreadLocal 简介:
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想要实现每一个线程都有自己的专属本地变量该如何解决呢?JDK 中提供的
ThreadLocal
类正是为了解决这样的问题。类似操作系统中的**
TLAB
。**
7.2 原理:
首先
ThreadLocal
是一个泛型类,保证可以接收任何类型的对象。因为一个线程内可以存在多个**
ThreadLocal
对象,所以其实是**
ThreadLocal
内部维护了一个**
Map
,是**
ThreadLocal
实现的一个叫做**
ThreadLocalMap
的静态内部类。
最终的变量是放在了当前线程的
ThreadLocalMap
中,并不是存在**
ThreadLocal
上,
ThreadLocadl
可以理解为只是**
ThreadLocalMap
的封装,传递了变量值。
我们使用的
get()
、
set()
方法其实都是调用了这个**
ThreadLocalMap
类对应的**
get()
、
set()
方法。
7.3 使用
- 存储用户 Session
private static final ThreadLocal threadSession = new ThreadLocal();
- 解决线程安全的问题
private static ThreadLocal<SimpleDateFormat> format = new ThreadLocal<SimpleDateFormat>();
7.4 ThreadLocal 内存泄漏的场景
实际上
ThreadLocalMap
中使用的**
key
为**
ThreadLocal
的弱引用,而**
value
是强引用。弱引用的特点是,如果这个对象持有弱引用,那么在下一次垃圾回收的时候必然会被清理掉。
所以如果
ThreadLocal
没有被外部强引用的情况下,在垃圾回收的时候会被清理掉的,这样一来**
ThreadLocalMap
中使用这个**
ThreadLocal
的 key 也会被清理掉。但是,**
value
是强引用,不会被清理,这样一来就会出现**
key
为**
null
的**
value
。如果我们不做任何措施的话,**
value
永远无法被**
GC
回收,如果线程长时间不被销毁,可能会产生内存泄漏。
**ThreadLocalMap
实现中已经考虑了这种情况,在调用
set()
、
get()
、
remove()
方法的时候,会清理掉
key
为
null
的记录。如果说会出现内存泄漏,那只有在出现了
key
为
null
的记录后,没有手动调用
remove()
方法,并且之后也不再调用
get()
、
set()
、
remove()
方法的情况下。因此使用完
ThreadLocal
方法后,最好**手动调用
remove()
方法**。
8. HashMap 线程安全
死循环造成 CPU 100%
**HashMap
有可能会发生死循环并且造成
CPU 100%
,这种情况发生最主要的原因就是在扩容的时候,也就是内部新建新的
HashMap
的时候,扩容的逻辑会反转散列桶中的节点顺序,当有多个线程同时进行扩容的时候,由于
HashMap
并非线程安全的,所以如果两个线程同时反转的话,便可能形成一个循环,并且这种循环是链表的循环,相当于 A 节点指向 B 节点,B 节点又指回到 A 节点,这样一来,在下一次想要获取该
key
所对应的 value 的时候,便会在遍历链表的时候发生永远无法遍历结束的情况,也就发生
CPU 100%
的情况。
所以综上所述,
HashMap
是线程不安全的,在多线程使用场景中推荐使用线程安全同时性能比较好的
ConcurrentHashMap
。
9. String 不可变原因
- 可以使用字符串常量池,多次创建同样的字符串会指向同一个内存地址;
- 可以很方便地用作
HashMap
的
key
。通常建议把不可变对象作为
HashMap
的
key
;
3.
hashCode
生成后就不会改变,使用时无需重新计算;
4. 线程安全,因为具备不变性的对象一定是线程安全的;