目录
3.4 对象头中的Mark Work 与线程中的Lock Record
5 并发包 java.util.concurrent.lock
1 线程安全
谈到锁,首先会想到锁解决的问题是什么? 锁是保护线程安全的一种机制。
先来看几个概念:
什么是线程?是cpu能够进行运算调度的最小单元。它被包含在进程中(进程是系统资源分配的最小单位)
什么是多线程?解决多任务同时执行的需求,合理使用CPU资源。多线程的运行是根据CPU切换完成,如何切换由CPU决定,因此多线程运行具有不确定性。
什么是线程安全呢? 在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行。
总结:操作系统为进程分配了资源,比如:地址空间、全局变量等等。线程是进程的一部分,CPU 调度的线程。
1.1 线程生命周期
上图是一个线程的生命周期状态流转图,很清楚的描绘了一个线程从创建到终止的过程。
这些状态的枚举值都定义在java.lang.Thread.State下:
NEW:毫无疑问表示的是刚创建的线程,还没有开始启动。
RUNNABLE: 表示线程已经触发start()方式调用,线程正式启动,线程处于运行中状态。
BLOCKED:表示线程阻塞,等待获取锁,如碰到synchronized、lock等关键字等占用临界区的情况,一旦获取到锁就进行RUNNABLE状态继续运行。
WAITING:表示线程处于无限制等待状态,等待一个特殊的事件来重新唤醒,如通过wait()方法进行等待的线程等待一个notify()或者notifyAll()方法,通过join()方法进行等待的线程等待目标线程运行结束而唤醒,一旦通过相关事件唤醒线程,线程就进入了RUNNABLE状态继续运行。
TIMED_WAITING:表示线程进入了一个有时限的等待,如sleep(3000),等待3秒后线程重新进行RUNNABLE状态继续运行。
TERMINATED:表示线程执行完毕后,进行终止状态。
需要注意的是,一旦线程通过start方法启动后就再也不能回到初始NEW状态,线程终止后也不能再回到RUNNABLE状态。
1.2 解决共享资源竞争
可以把单线程程序当成在问题域求解的单一实体,每次只能做一件事情。因为只有一个实体,所以永远不会担心诸如“两个实体试图同时使用同一个资源”的问题。换个角度来看,如果资源不是共享的,或者不是可修改的,也就不存在线程安全的问题。
使用线程的时候有一个基本问题:你永远都不知道一个线程何时在运行。
基本上所有的并发模式在解决线程冲突问题的时候,都是采用序列化访问共享资源的方案。这意味着在给定时刻只允许一个任务访问共享资源,通常这是通过加锁来实现的。
2 锁
2.1 锁的分类
参考文章:https://tech.meituan.com/2018/11/15/java-lock.html 通过对锁的特性对锁进行分类如下:
结合线程的生命周期,
2.2 悲观锁VS乐观锁
2.2.1 概念介绍
悲观锁:总是假设最坏的情况。自己在使用共享数据的时候,别的线程一定会来修改数据,所以在使用数据之前,加锁。
synchronized关键字和Lock的实现类都是悲观锁。
乐观锁:总是假设最好的情况。自己在使用共享数据的时候,别的线程不会来修改数据,所以使用数据的时候不加锁。但是在更新数据的时候,会判断一下在此期间,有没有别的线程去更新这个数据。如果没有更新,则将自己要更新的数据写入,如果数据资源已经被别的线程更新过,则执行不同的操作(报错或者重试)。
乐观锁一般会使用版本号机制或者CAS算法实现。java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
代码示例:
// ------------------------- 悲观锁的调用方式 -------------------------
// synchronized
public synchronized void testMethod() {
// 操作同步资源
}
// ReentrantLock
private ReentrantLock lock = new ReentrantLock(); // 需要保证多个线程使用的是同一个锁
public void modifyPublicResources() {
lock.lock();
// 操作同步资源
lock.unlock();
}
// ------------------------- 乐观锁的调用方式 -------------------------
private AtomicInteger atomicInteger = new AtomicInteger(); // 需要保证多个线程使用的是同一个AtomicInteger
atomicInteger.incrementAndGet(); //执行自增1
2.2.2 版本号机制 modCount
一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
举一个简单的例子:假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。
- 操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。
- 在操作员 A 操作的过程中,操作员B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。
- 操作员 A 完成了修改工作,将数据版本号加一( version=2 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
- 操作员 B 完成了操作,也将版本号加一( version=2 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 2 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须大于记录当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。
这样,就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员A 的操作结果的可能。
2.2.3 CAS 算法
即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数
- 需要读写的内存值 V
- 进行比较的值 A
- 拟写入的新值 B
当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
2.3 可重入锁VS非可重入锁
可重入锁,如果外层方法和内层方法都加可重入锁, 同一个线程在外层方法获取锁之后,再进入内层方法会自动获取锁,不会因为之前已经获取过锁还没有释放()而阻塞。
是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。下面用示例代码来进行分析:
public class Widget {
public synchronized void doSomething() {
System.out.println("方法1执行...");
doOthers();
}
public synchronized void doOthers() {
System.out.println("方法2执行...");
}
}
类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。
如果是一个不可重入锁,那么当前线程在调用doOthers()之前需要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。
2.4 公平锁和非公平锁
公平锁:公平锁表示线程获取锁的顺序是按照 线程加锁的顺序 来分配的,即先来先得的 FIFO 先进先出顺序。每个线程获取锁的过程是公平的,等待时间最长的会最先被唤醒获取锁。
非公平锁:非公平锁就是一种获取锁的抢占机制,是 随机获得锁 的,和公平锁不一样的就是先来的不一定先得到锁,这个方式可能造成某些线程一直拿不到锁,结果也就是不公平的了。
2.5 使用场景总结
简单的来说CAS适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多)
- 对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
- 对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
3 锁的实现原理
3.1 synchronized 底层实现原理
synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,然后实现线程同步。synchronized 的锁存在于java 的对象头,对象头中最后两位表示 锁的标志位,对象头中还包含有指向monitor对象的指针,synchronized 的线程同步是通过 Monitor 对象来实现的。
Monitor 是JVM 通过调用操作系统的互斥原语mutex 来实现的,被阻塞的线程会被挂起,等待重新调度。所以可以理解为:synchronized 最初也是个重量级的锁。但是JVM 又对synchronized 的运行机制做了优化,提供了三种不同的 Monitor 实现,也就是三种常见的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁。当JVM 检测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级和降级。
我们来看一下Sychronized 对代码段加锁反编译的过程:
package SynchronizedDemo;
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("Method 1 start");
}
}
}
执行:javac SynchronizedDemo.java
javap -v SynchronizedDemo.class
之后如下:
Monitor 对象主要有 monitorenter/ monitorexit 一对儿指令。
3.2 Monitor (监视器锁)
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每个对象都是一个监视器锁(monitor)。
Monitor 对象主要有 monitorenter/ monitorexit 一对儿指令。
synchronized 是如何通过 monitorenter 和 monitorexit 来实现线程同步的,具体过程如下:
monitorenter:
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
- 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
- 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
monitorexit:
- 执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
- monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;
3.3 对象头
上面我们提到,Monitor对象存在于每个Java对象的对象头Mark Word(运行时数据)中。
在JVM 中,对象在内存中的布局为三块区域:对象头、实例数据和对齐填充。对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。
Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit)但是 如果对象是数组类型,则需要3个机器码。
对象头的最后两位存储了锁的标志位,01是初始状态,未加锁,其对象头里存储的是对象本身的哈希码,随着锁级别的不同,对象头里会存储不同的内容。偏向锁存储的是当前占用此对象的线程ID;而轻量级则存储指向线程栈中锁记录的指针。
从这里我们可以看到,“锁”这个东西,可能是个锁记录+对象头里的引用指针(判断线程是否拥有锁时将线程的锁记录地址和对象头里的指针地址比较),也可能是对象头里的线程ID(判断线程是否拥有锁时将线程的ID和对象头里存储的线程ID比较)。
锁状态 | 存储内容 | 存储内容 |
---|---|---|
无锁 | 对象的hashCode、对象分代年龄、是否是偏向锁(0) | 01 |
偏向锁 | 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 |
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 |
3.4 对象头中的Mark Work 与线程中的Lock Record
在线程进入同步代码块的时候,如果此同步对象没有被锁定,即它的锁标志位是01,则虚拟机首先在当前线程的栈中创建我们称之为“锁记录(Lock Record)”的空间,用于存储锁对象的Mark Word的拷贝。
Lock Record是线程私有的数据结构,每一个线程都有一个可用Lock Record列表,同时还有一个全局的可用列表。每一个被锁住的对象Mark Word都会和一个Lock Record关联(对象头的MarkWord中的Lock Word指向Lock Record的起始地址),同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识
3.5 锁的升级、降级
我们上面说道,JVM 提供了三种不同的Monitor 对象的实现,也就是三种常见的锁:偏向锁、轻量级锁和重量级锁。详见2.1锁的分类。
当竞争出现时,默认会使用偏向锁。JVM 会利用CAS 操作,在对象头的 mark word 部分设置线程ID ,以表示这个对象偏向于当前的线程,所以并不涉及到真正的互斥锁。 这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏向锁可以降低无竞争开销。
如果有另外的线程试图锁定某个已经被偏向过的对象,JVM 就需要撤销(revoke)偏向锁,并切换到轻量级锁。 轻量级锁利用CAS 操作mark word 来试图获取锁,如果尝试成功,就使用普通的轻量级锁(锁标识位为00);否则,进一步升级为重量级锁。
说明: synchronized 是JVM 内部的 Intrinsic Lock,所以偏向锁、轻量级锁、重量级锁的实现是在JVM 代码中。
参考: synchronizer.cpp
4 reentrantLock 底层实现原理
ReentrantLock是一个可重入的互斥锁,又被称为“独占锁”。它添加了类似锁投票、定时锁等候和可中断锁等候的一些特性。可以配合一个或多个Condition条件方便的实现等待通知机制。
ReentrantLock
构造函数中提供了两种锁:创建公平锁和非公平锁(默认)。ReentraantLock是通过一个FIFO的等待队列来管理获取该锁所有线程的。
- RentrantLock 有三个内部类 Sync、NonfairSync 和 FairSync 类。
- Sync 继承 AbstractQueuedSynchronizer 抽象类。
- NonfairSync(非公平锁) 继承 Sync 抽象类。
- FairSync(公平锁) 继承 Sync 抽象类。
- 公平锁:如果有另一个线程持有锁或者有其他线程在等待队列中等待这个锁,那么新发出的请求的线程将被放入到队列中。
- 非公平锁:只有当锁被某个线程持有时,新发出请求的线程才会被放入队列中。当一个线程请求非公平锁时,如果在发出请求的同时该锁变成可用状态,那么这个线程会跳过队列中所有的等待线程而获得锁。
非公平锁性能高于公平锁性能的原因:
- 在恢复一个被挂起的线程与该线程真正运行之间存在着严重的延迟。
- 假设线程 A 持有一个锁,并且线程 B 请求这个锁。由于锁被 A 持有,因此 B 将被挂起。当 A 释放锁时,B 将被唤醒,因此 B 会再次尝试获取这个锁。与此同时,如果线程 C 也请求这个锁,那么 C 很可能会在 B 被完全唤醒之前获得、使用以及释放这个锁。这样就是一种双赢的局面,B 获得锁的时刻并没有推迟,C 更早的获得了锁,并且吞吐量也提高了。
所以,当持有锁的时间相对较长或者请求锁的平均时间间隔较长,应该使用公平锁。在这些情况下,插队带来的吞吐量提升(当锁处于可用状态时,线程却还处于被唤醒的过程中)可能不会出现。
4.2 ReentrantLock 方法
方法 | 说明 |
---|---|
getHoldCount() | 查询当前线程获取此锁的次数,此线程执行 lock 方法的次数。 |
getQueueLength() | 返回正等待获取此锁的线程估计数,比如启动 10 个线程,1 个线程获得锁,此时返回的是 9。 |
getWaitQueueLength(Condition condition) | 返回等待与此锁相关的给定条件的线程估计数。比如 10 个线程,用同一个 condition 对象,并且此时这 10 个线程都执行了 condition 对象的 await 方法,那么此时执行此方法返回 10。 |
hasWaiters(Condition condition) | 查询是否有线程等待与此锁有关的给定条件(condition),对于指定 contidion 对象,有多少线程执行了 condition.await 方法。 |
hasQueuedThread(Thread thread) | 查询给定线程是否等待获取此锁。 |
hasQueuedThreads() | 是否有线程等待此锁。 |
isFair() | 该锁是否公平锁。 |
isHeldByCurrentThread() | 当前线程是否保持锁锁定,线程的执行 lock 方法的前后分别是 false 和 true。 |
isLock() | 此锁是否有任意线程占用。 |
lockInterruptibly() | 如果当前线程未被中断,获取锁。 |
tryLock() | 尝试获得锁,仅在调用时锁未被线程占用,获得锁。 |
tryLock(long timeout, TimeUnit unit) | 如果锁在给定等待时间内没有被另一个线程获取,则获取该锁。 |
参考: 用法 https://www.cnblogs.com/takumicx/p/9338983.html
源码 https://www.cnblogs.com/takumicx/p/9402021.html
5 并发包 java.util.concurrent.lock
synchronized是java中的一个关键字,也就是说是Java语言内置的特性。那么为什么会出现Lock呢?我们知道,如果一个代码块被synchronized修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况: 1)获取锁的线程执行完了该代码块,然后线程释放对锁的占有;2)线程执行发生异常,此时JVM会让线程自动释放锁。
那么如果这个获取锁的线程由于要等待IO或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能等待,为了提高效率,就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断),通过Lock就可以办到。
类别 | synchronized | java.util.concurrent.locks | ReentraintLock |
---|---|---|---|
存在层次 | Java的关键字,在jvm层面上,内置特性 | 是一个类,通过这个类可以实现同步访问 | ReentrantLock是唯一实现了Lock接口的类,并且ReentrantLock提供了更多的方法 |
锁的释放 | 1、以获取锁的线程执行完同步代码,释放锁 2、线程执行发生异常,jvm会让线程释放锁; 用户不用手动操作,优点是操作简单,但显得不够灵活。一般并发场景使用synchronized的就够了 | 在finally中必须释放锁,不然容易造成线程死锁 | ReentrantLock需要手动加锁和解锁,且解锁的操作尽量要放在finally代码块中,保证线程正确释放锁。 |
锁的获取 | 假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待 | 分情况而定,Lock有多个锁获取的方式,线程可以不用一直等待 | 同lock |
锁状态 | 无法判断 | 可以判断 | 可以判断 |
锁类型 | 可重入 不可中断 非公平 | 可重入 可判断 可公平(两者皆可) | 可重入锁、可中断、公平/非公平锁 |
性能 | 少量同步 | 大量同步 | 大量同步 |
扩展: https://www.fangzhipeng.com/javainterview/2019/03/23/synchronized-base.html
https://www.jianshu.com/p/e62fa839aa41