Java同步锁
Java同步锁
1. Java锁的基本概念
1.1 锁的种类
https://blog.csdn.net/qq_41931837/article/details/82314478
1.1.1 乐观锁和悲观锁
悲观锁认为一定会有别的线程抢占数据,因此操作数据前都要先获取数据的锁,synchronized
和 lock
都是悲观锁,适用于写操作较多的场景。
乐观锁认为不会有别的线程抢占数据,因此只在写操作前判断是否有其他线程更新了数据,CAS(Compare And Swap)就是乐观锁的一种实现方式,适用于读操作较多的场景。
1.1.2 公平锁和非公平锁
公平锁是多个线程按照申请锁的顺序来获取锁,非公平锁则不规定线程顺序。synchronized是非公平锁,ReentrantLock可以通过指定构造方法的参数来创建公平或非公平锁。
1.1.3 独享锁和共享锁
独享锁即一次只能被一个线程持有,共享锁一次可以被多个线程池有,synchronized和ReentrantLock都是独享锁,ReentrantReadWriteLock的读锁是共享锁,写锁是独享锁。
1.1.4 互斥锁和读写锁
和独享、共享是一个意思。
1.1.5 可重入锁和不可重入锁
1 | public sychrnozied void test() { |
Reentrant
就是可重入的意思,所以 Reentrant
和 synchronized
都是可重入锁,也即上面的代码可以顺利执行,test()
获取了对象锁后,再调用 test2()
时,可以直接用已持有的对象锁直接进入 test2()
,如果是不可重入锁则会发生死锁。
1.1.6 自旋锁
自旋锁就是使用循环尝试获取来替代线程阻塞等待,好处是减少线程切换的消耗,但会增加CPU资源消耗。
1.1.7 偏向锁、轻量级锁、重量级锁
这是三种锁的状态,是针对 synchronized 字段优化的,通过对象监视器在内存中的头部字段来标明。
- 偏向锁是指,如果一段同步代码一直被一个线程访问,则该线程会自动获取锁而不需要申请,以降低获取锁的资源消耗。
- 轻量锁是指如果锁是偏向锁时,有另一个线程访问了同步代码块,则将偏向锁改为轻量锁。此时另外的线程不会阻塞等待,而是通过自旋来尝试获取锁。
- 重量级锁则是,如果锁是轻量锁,另一个线程虽然是自旋,但不会一直持续下去,当自旋超过一定次数还没有获取到锁,就会进入阻塞状态,该锁膨胀为重量级锁。
1.1.8 分段锁
分段锁不是一种具体的锁,也不是锁的形式,只是一种设计方案,在ConcurrentHashMap内部使用,通过将数据分段并分别加锁,来提升并发情况下的写入性能。
1.1.9 同步锁
(1)对象锁(方法锁)
1 | synchronized(Object object) { ... } |
当多个线程使用同一个对象锁则可以达到同步的效果,否则无效。
当 synchronized
修饰方法时,实际上等同于获取该方法所在的外部类对象的对象锁。
(2)类锁(静态锁)
1 | synchronized(DemoClass.class) { ... } |
同一个类只有一个类锁,但每个类的实例对象都有自己的对象锁,因此当使用类锁时,所有该类的实例对象拿到的都是同一把锁,而使用对象锁时,不同实例对象之间是无关的。
synchronized
修饰静态方法时,即为静态锁。由于非静态方法会自动持有其所在外部类对象的引用,因此非静态方法加锁等同于其所在外部类实例对象的对象锁,而静态方法不会持有其外部类对象的引用,因此是使用其外部类的字节码对象作为锁。
1.2 锁粗化和锁消除
锁粗化和锁消除的本意都是尽量减少锁的获取和释放,节约资源。
锁消除是虚拟机在编译阶段做逃逸分析时,判断到某段代码,虽然加了锁,但不可能会被其他线程访问到,因此加锁是没必要的,则在编译阶段就去除加锁部分。
锁粗化可以理解为扩展锁的域,例如在一个循环内部对一个操作加锁,由于重复获取和释放锁会带来很大的开销,因此虚拟机在编译阶段,将锁粗化,加载循环体外部。
2. Synchronized关键字
synchronized
只会同步尝试获取同一个 monitor
对象的线程,如果一个线程已经持有该 monitor
,则后续所有尝试持有该 monitor
的线程都需要依次等待前面的线程释放 monitor
才能继续执行。
- 对非静态方法加
synchronized
相当于对象锁。 - 对静态方法加
synchronized
相等于类锁。 - 用常量池和自动装箱的基本变量作为监视器,一旦改变了值就相当于改变了监视器。
2.1 Synchronized如何保证原子性
所谓原子性指的是不会被线程调度机制打断的操作。java中对基本数据类型的读和写是原子性操作,但自增自减不是。例如 i++
在 JVM 中实际上是三步:
- 取出
i
的值 - 对值做
+1
操作 - 重新赋值给
i
虽然其中的每一步都是原子性操作,但这个过程并不是原子性的。所以如果需要保证原子性,可以通过 synchronized
关键字实现:
1 | synchronized (this) {i++;} |
或者使用原子类 AtomicInteger
、AtomicLong
、AtomicReference
等,其中 AtomicInteger#getAndIncrement()
效果相当于 i++
,AtomicInteger#getAndDecrement()
效果相当于 i--
,AtomicInteger#incrementAndGet()
效果相当于 ++i
,其内部原理:
1 | public final int getAndIncrement() { |
3. Lock
3.1 Lock的基本用法
Lock
的基本用法为:Lock lock = new ReentrantLock();
,可以通过 lock.lock()
手动加锁,通过 lock.unlock()
手动释放锁。但 Lock
的性能很差,需要注意在 finally
中释放锁。
1 | Lock lock = new ReentrantLock(); |
3.2 Lock的实现原理
https://yq.aliyun.com/articles/640868
Lock
本质还是通过 CAS 乐观锁 实现的。一个线程尝试去获取锁,如果获取到,则更新一个 state
字段,表示当前是正在等待锁还是持有锁还是释放锁的状态,然后去执行操作。否则如果没获取到,则将该线程放到记录等待线程的双向链表中,然后线程做自旋,直到获取到锁。Lock
建议在低锁冲突的情况下用,否则非必要(例如需要公平锁)的情况下,还是使用 synchronized
更好。
4. 基于原生方法手动实现公平锁
https://blog.csdn.net/sddh1988/article/details/68068971
使用队列,在 lock()
内按顺序添加等待的线程名 currentThread()#getName()
,然后 peek
出最前的线程名,自旋对比当前线程是否是 peek
出来的线程,如果不是则一直循环。
5. 死锁
死锁产生有四个必要条件:
- 互斥(线程要求的资源仅能被一个线程所独占)
- 保持(线程因请求被其他线程独占的资源而阻塞时,保持已获得的资源不释放)
- 不剥夺(线程独占的资源未使用完之前,不可剥夺该线程对资源的独占)
- 环路等待(发生死锁时,必然存在线程-资源的环形请求链)。
预防死锁的方式有:
- 避免运行中请求资源,线程所需的资源一次性分配
- 一个线程如果请求独占某个资源失败,则其他的资源也拒绝被该线程独占
- 如果一个线程请求独占某个资源失败,则释放该线程原有的独占资源
- 给资源进行编号,线程按顺序请求资源,逆序释放资源
- 超时放弃,尝试获取锁一段时间不成功,则放弃自身独占的资源(如
Lock#tryLock(long time, TimeUnit unit)
)
6. wait和sleep
https://www.cnblogs.com/loren-Yang/p/7538482.html
sleep()
是Thread
类中的方法,wait()
是Object
类中的方法,因此所有的对象都能调用wait()
。wait()
会释放该对象持有的锁资源,所以一旦一个对象调用了wait()
,其他线程可以通过notify()
或notyfyAll()
来唤醒,而sleep()
不释放锁资源,因此其他线程无法使用同步控制块。
7. Volatile关键字
volatile
一般用在多个线程访问同一个变量时,对该变量进行唯一性约束,volatile
保证了变量的可见性,但不能保证原子性。例如:
1 | int i = 1; |
上述代码,在某些极端情况下,可能会输出 1,也就是:
- write 线程先执行了
flag = true
- 然后 read 线程执行了
if
判断并通过,输出 1 - 然后 write 线程才执行
i = 2
为了避免这个情况,可以如下改写:
1 | int i = 1; |
volatile
用于告诉 JVM 变量不允许线程缓存以及代码重排序,会使得其所在域的写操作一定发生在读操作之前,且每次有写操作后,都将所在域中的写入值同步到主内存中,从而避免其他线程从缓存中拿到旧数据。