1.共享模型之管程
- 共享带来的问题
- synchronized解决方案
- 变量的线程安全分析
- Monitor概念
- wait和notify
- park&unpark
- 线程状态转换
- 活跃性
- ReentrantLock
1.1 共享带来的问题
线程出现问题的根本原因是因为线程上下文切换,导致线程里的指令没有执行完就切换执行其它线程了,下面举一个例子
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 1;i<5000;i++){
count++;
}
});
Thread t2 =new Thread(()->{
for (int i = 1;i<5000;i++){
count--;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("count的值是{}",count);
}
从字节码的层面进行分析:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i
可以看到count++
和count--
操作实际都是需要这个4个指令完成的,那么这里问题就来了,Java的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:
如果代码是正常按顺序运行的,那么count的值不会计算错
但多线程下的代码可能交错运行
出现负数的情况:
出现正数的情况:
临界区 Critical Section
- 一个程序运行多线程本身是没有问题的
- 问题出现在多个线程访问共享资源的时候
- 多个线程同时对共享资源进行读操作本身也没有问题
- 问题出现在对共享资源同时进行读写操作时就有问题了
- 先定义一个叫做临界区的概念:一段代码内如果存在对共享资源的多线程读写操作,那么称这段代码为临界区
例如,下面代码中的临界区
static int counter = 0;
static void increment()
// 临界区
{
counter++;
}
static void decrement()
// 临界区
{
counter--;
}
竞态条件 Race Condition
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
1.2 synchronized解决方案
为了避免临界区中的竞态条件发生,由多种手段可以达到
- 阻塞式解决方案:synchronized,Lock
- 非阻塞式解决方案:原子变量
现在讨论使用synchronized来进行解决,即俗称的对象锁,它采用互斥的方式让同一时刻至多只有一个线程持有对象锁,其他线程如果想获取这个锁就会阻塞住,这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
**注意 **
虽然java中互斥和同步都可以采用synchronized关键字来完成,但它们还是有区别的:
- 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区的代码
- 同步是由于线程执行的先后,顺序不同但是需要一个线程等待其它线程运行到某个点。
synchronized
synchronized(对象) // 线程1获得锁, 那么线程2的状态是(blocked)
{
临界区
}
上面的实例程序使用synchronized后如下,计算出的结果是正确
static int counter = 0;
static final Object room = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (room) {
counter++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (room) {
counter--;
}
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("{}",counter);
}
synchronized原理
synchronized实际上利用对象保证了临界区代码的原子性,临界区内的代码在外界看来是不可分割的,不会被线程切换所打断
synchronized实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。
应该把所用使用到共享资源的线程都加上锁.
面向对象改进
把需要保护的共享变量放入一个类
class Room {
int value = 0;
public void increment() {
synchronized (this) {
value++;
}
}
public void decrement() {
synchronized (this) {
value--;
}
}
public int get() {
synchronized (this) {
return value;
}
}
}
@Slf4j
public class Test1 {
public static void main(String[] args) throws InterruptedException {
Room room = new Room();
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.increment();
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.decrement();
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("count: {}" , room.get());
}
}
synchronized加在方法上
class Test{
public synchronized void test() {
}
}
//等价于
class Test{
public void test() {
synchronized(this) {
}
}
}
//------------------------------------------------------------------------------------------------
class Test{
public synchronized static void test() {
}
}
// 等价于
class Test{
public static void test() {
synchronized(Test.class) {
}
}
}
锁加static代表锁住的类对象,锁加在方法上是锁住的this对象
1.3 变量的线程安全分析
1.3.1 成员变量和静态变量的线程安全分析
- 如果没有变量没有在线程间共享,那么变量是安全的
- 如果变量在线程间共享
- 如果只有读操作,则线程安全
- 如果有读写操作,则这段代码是临界区,需要考虑线程安全
1.3.2 局部变量线程安全分析
- 局部变量【局部变量被初始化为基本数据类型】是安全的
- 局部变量引用的对象未必是安全的
- 如果局部变量引用的对象没有引用线程共享的对象,那么是线程安全的
- 如果局部变量引用的对象引用了一个线程共享的对象,那么要考虑线程安全
线程安全的情况
局部变量【局部变量被初始化为基本数据类型】是安全的,示例如下
public static void test1() {
int i = 10;
i++;
}
每个线程调用test1()方法时局部变量i,会在每个线程的栈帧内存中被创建多份,因此不存在共享
线程不安全的情况
如果局部变量引用的对象逃离方法的范围,那么要考虑线程安全的,代码示例如下
public class Test15 {
public static void main(String[] args) {
UnsafeTest unsafeTest = new UnsafeTest();
for (int i =0;i<100;i++){
new Thread(()->{
unsafeTest.method1();
},"线程"+i).start();
}
}
}
class UnsafeTest{
ArrayList<String> arrayList = new ArrayList<>();
public void method1(){
for (int i = 0; i < 100; i++) {
method2();
method3();
}
}
private void method2() {
arrayList.add("1");
}
private void method3() {
arrayList.remove(0);
}
}
不安全原因分析
无论哪个线程中的method2和method3引用的都是同一个对象中的list成员变量:一个ArrayList,在添加一个元素的时候,它可能会有两步来完成:
- 第一步,在arrayList[Size]的位置存放此元素;第二步增大Size的值。
- 在单线程运行的情况下,如果Size=0,添加一个元素后,此元素在位置0,而且Size=1;而如果是在多线程情下,比如有两个线程,线程1先将元素存放在位置0。但是此时CPU调线程A暂停,线程2得到运行的机会。线程2也向此ArrayList添加元素,因为此时Size仍等于0(注意,我们假设的是添加一个元素是要两个步骤,而线程1仅仅完成了步骤1),所以线程2也将元素存放在位置0。然后线程1和线程2都继续运行,都增加Size的值。现在来看看ArrayList的情况,元素实际上只有一个,存放在位置0,而Size却等于2。这就是“线程不安全”了。
解决方法
可以将list修改成局部变量,那么就不会有上述问题了
class safeTest{
public void method1(){
ArrayList<String> arrayList = new ArrayList<>();
for (int i = 0; i < 100; i++) {
method2(arrayList);
method3(arrayList);}
}
private void method2(ArrayList arrayList) {
arrayList.add("1");
}
private void method3(ArrayList arrayList) {
arrayList.remove(0);
}
}
分析:
list是局部变量,每个线程调用时会创建其不同实例,没有共享,而method2的参数是从method1中传递过来的,与method1中引用同一个对象,method3的参数分析与method2相同.
private或final的重要性
方法访问修饰符带来的思考,如果把method2和method3的方法修改为public会不会导致线程安全问题?情况1:有其它线程调用method2和method3;情况2:在情况1的基础上,为ThreadSafe类添加子类,子类覆盖method2或method3方法,即如下所示:从这个例子可以看出private或final提供【安全】的意义所在,请体会开闭原则中的【闭】.
class ThreadSafe {
public final void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
method2(list);
method3(list);
}
}
private void method2(ArrayList<String> list) {
list.add("1");
}
private void method3(ArrayList<String> list) {
list.remove(0);
}
}
class ThreadSafeSubClass extends ThreadSafe{
@Override
public void method3(ArrayList<String> list) {
new Thread(() -> {
list.remove(0);
}).start();
}
}
1.3.3 常见线程安全类
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent包下的类
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为它们的每个方法是原子的
Hashtable table = new Hashtable();
new Thread(()->{
table.put("key", "value1");
}).start();
new Thread(()->{
table.put("key", "value2");
}).start();
线程安全类方法的组合
但注意它们多个方法的组合不是原子的,见下面分析
sequenceDiagram
participant t1 as 线程1
participant t2 as 线程2
participant table
t1->>table:get("key")==null
t2->>table:get("key")==null
t2->>table:put("key",v2)
t1->>table:put("key",v1)
Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
table.put("key", value);
}
不可变类的线程安全
String
和Integer
类都是不可变的类,因为其类内部状态是不可改变的,因此它们的方法都是线程安全的.String
有replace
,substring
等方法【可以】改变值,其实调用这些方法返回的已经是一个新创建的对象了.
public class Immutable{
private int value = 0;
public Immutable(int value){
this.value = value;
}
public int getValue(){
return this.value;
}
public Immutable add(int v){
return new Immutable(this.value + v);
}
}
实例分析-是否线程安全
分析线程是否安全,先对类的成员变量,类变量,局部变量进行考虑,如果变量会在各个线程之间共享,那么就得考虑线程安全问题了,如果变量A引用的是线程安全类的实例,并且只调用该线程安全类的一个方法,那么该变量A是线程安全的。
实例一
此类不是线程安全的,MyAspect
切面类只有一个实例,成员变量start
会被多个线程同时进行读写操作
@Aspect
@Component
public class MyAspect {
// 是否安全?
private long start = 0L;
@Before("execution(* *(..))")
public void before() {
start = System.nanoTime();
}
@After("execution(* *(..))")
public void after() {
long end = System.nanoTime();
System.out.println("cost time:" + (end-start));
}
}
实例二
此例是典型的三层模型调用,MyServlet
,UserServiceImpl
,UserDaoImpl
类都只有一个实例,UserDaoImpl
类中没有成员变量,update
方法里的变量引用的对象不是线程共享的,所以是线程安全的;UserServiceImpl
类中只有一个线程安全的UserDaoImpl
类的实例,那么UserServiceImpl
类也是线程安全的,同理MyServlet
也是线程安全的
public class MyServlet extends HttpServlet {
// 是否安全
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update(...);
}
}
public class UserServiceImpl implements UserService {
// 是否安全
private UserDao userDao = new UserDaoImpl();
public void update() {
userDao.update();
}
}
public class UserDaoImpl implements UserDao {
public void update() {
String sql = "update user set password = ? where username = ?";
// 是否安全
try (Connection conn = DriverManager.getConnection("","","")){
// ...
} catch (Exception e) {
// ...
}
}
}
实例三
跟实例二大体相似,UserDaoImpl
类中有成员变量,那么多个线程可以对成员变量conn
同时进行操作,故是不安全的
public class MyServlet extends HttpServlet {
// 是否安全
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update(...);
}
}
public class UserServiceImpl implements UserService {
// 是否安全
private UserDao userDao = new UserDaoImpl();
public void update() {
userDao.update();
}
}
public class UserDaoImpl implements UserDao {
// 是否安全
private Connection conn = null;
public void update() throws SQLException {
String sql = "update user set password = ? where username = ?";
conn = DriverManager.getConnection("","","");
// ...
conn.close();
}
}
实例四
跟示例三大体相似,UserServiceImpl
类的update方法中UserDao是作为局部变量存在的,所以每个线程访问的时候都会新建有一个UserDao
对象,新建的对象是线程独有的,所以是线程安全的
public class MyServlet extends HttpServlet {
// 是否安全
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update(...);
}
}
public class UserServiceImpl implements UserService {
public void update() {
UserDao userDao = new UserDaoImpl();
userDao.update();
}
}
public class UserDaoImpl implements UserDao {
// 是否安全
private Connection = null;
public void update() throws SQLException {
String sql = "update user set password = ? where username = ?";
conn = DriverManager.getConnection("","","");
// ...
conn.close();
}
}
实例五
public abstract class Test {
public void bar() {
// 是否安全
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
foo(sdf);
}
public abstract foo(SimpleDateFormat sdf);
public static void main(String[] args) {
new Test().bar();
}
}
其中foo的行为是不确定的,可能导致不安全的发生,被称之为外星方法,因为foo方法可以被重写,导致线程不安全。在String类中就考虑到了这一点,String类是finally
的,子类不能重写它的方法。
public void foo(SimpleDateFormat sdf) {
String dateStr = "1999-10-11 00:00:00";
for (int i = 0; i < 20; i++) {
new Thread(() -> {
try {
sdf.parse(dateStr);
} catch (ParseException e) {
e.printStackTrace();
}
}).start();
}
}
1.4 Monitor概念
Java对象头
以32位虚拟机为例,普通对象的对象头结构如下,其中的Klass Word为指针,指向对应的Class对象;
数组对象
其中Mark Word结构为
64位虚拟机Mark Word
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xFoMfmVQ-1693292701847)(C:\Users\李茂森\AppData\Roaming\Typora\typora-user-images\image-20221002210639082.png)]
所以一个对象的结构如下:
Monitor原理
Monitor被翻译为监视器或者说管程
每个java对象都可以关联一个Monitor,如果使用synchronized
给对象上锁(重量级),该对象头的Mark Word中就被设置为指向Monitor对象的指针
- 刚开始时Monitor中的Owner为null
- 当Thread-2执行synchronized(obj){}代码时就会将Monitor的所有者Owner设置为Thread-2,上锁成功,Monitor中同一时刻只能有一个Owner
- 当Thread-2占据锁时,如果线程Thread-3,Thread-4也来执行synchronized(obj){}代码,就会进入EntryList中变成BLOCKED状态
- Thread-2执行完同步代码块的内容,然后唤醒EntryList中等待的线程来竞争锁,竞争时是非公平的
- 图中WaitSet中的Thread-0,Thread-1是之前获得过锁,但条件不满足进入WAITING状态的线程,后面讲wait-notify时会分析
注意:synchronized必须是进入同一个对象的monitor才有上述的效果,不加synchronized的对象不会关联监视器,不遵从以上规则
synchronized原理进阶
static final Object lock=new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
反编译后的部分字节码
0 getstatic #2 <com/concurrent/test/Test17.lock>
# 取得lock的引用(synchronized开始了)
3 dup
# 复制操作数栈栈顶的值放入栈顶,即复制了一份lock的引用
4 astore_1
# 操作数栈栈顶的值弹出,即将lock的引用存到局部变量表中
5 monitorenter
# 将lock对象的Mark Word置为指向Monitor指针
6 getstatic #3 <com/concurrent/test/Test17.counter>
9 iconst_1
10 iadd
11 putstatic #3 <com/concurrent/test/Test17.counter>
14 aload_1
# 从局部变量表中取得lock的引用,放入操作数栈栈顶
15 monitorexit
# 将lock对象的Mark Word重置,唤醒EntryList
16 goto 24 (+8)
# 下面是异常处理指令,可以看到,如果出现异常,也能自动地释放锁
19 astore_2
20 aload_1
21 monitorexit
22 aload_2
23 athrow
24 return
注意:方法级别的synchronized不会在字节码指令中有所体现
轻量级锁
轻量级锁的使用场景是:如果一个对象虽然有多个线程要对它进行加锁,但是加锁的时间是错开的(也就是没有人可以竞争的),那么可以使用轻量级锁来进行优化。轻量级锁对使用者是透明的,即语法仍然是synchronized
,假设有两个方法同步块,利用同一个对象加锁
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
-
每次指向到synchronized代码块时,都会创建锁记录(Lock Record)对象,每个线程都会包括一个锁记录的结构,锁记录内部可以储存对象的Mark Word和对象引用reference(一个方法产生一个栈帧)
-
让锁记录中的Object reference指向对象,并且尝试用cas(compare and sweep)替换Object对象的Mark Word,将Mark Word的值存入锁记录中
-
如果cas替换成功,那么对象的对象头储存的就是锁记录的地址和状态00(轻量级锁),如下所示
-
如果cas失败,有两种情况
- 如果是其它线程已经持有了该Object的轻量级锁,那么表示有竞争,将进入锁膨胀阶段
- 如果是自己的线程已经执行了synchronized进行加锁,那么那么再添加一条Lock Record作为重入的计数
-
当线程退出synchronized代码块的时候,如果获取的是取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
-
当线程退出synchronized代码块的时候,如果获取的锁记录取值不为null,那么使用cas将Mark Word的值恢复给对象
- 成功则解锁成功
- 失败,则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
锁膨胀
如果在尝试加轻量级锁的过程中,cas操作无法成功,这是有一种情况就是其它线程已经为这个对象加上了轻量级锁,这是就要进行锁膨胀,将轻量级锁变成重量级锁。
-
当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁
-
这时Thread-1加轻量级锁失败,进入锁膨胀流程
- 即为对象申请Monitor锁,让Object指向重量级锁地址,然后自己进入Monitor的EntryList变成BLOCKED状态
-
当Thread-0退出synchronized同步块时,使用cas将Mark Word的值恢复给对象头,失败(地址后两位变成了10),那么会进入重量级锁的解锁过程,即按照Monitor的地址找到Monitor对象,将Owner设置为null,唤醒EntryList中的Thread-1线程
自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即在自旋的时候持锁的线程释放了锁),那么当前线程就可以不用进行上下文切换就获得了锁
-
自旋重试成功的情况
-
自旋重试失败的情况,自旋了一定次数还是没有等到持锁的线程释放锁
自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势。在Java 6之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能Java 7之后不能控制是否开启自旋功能
偏向锁
在轻量级的锁中,我们可以发现,如果同一个线程对同一个对象进行重入锁时,也需要执行CAS操作,这是有点耗时的,那么java6开始引入了偏向锁的,只有第一次使用CAS时将对象的Mark Word头设置为入锁线程ID,之后这个入锁线程再进行重入锁时,发现线程ID是自己的,那么就不用再进行CAS了
偏向状态
一个对象的创建过程
- 如果开启了偏向锁(默认是开启的),那么对象刚创建之后,Mark Word最后三位的值101,并且这是它的Thread,epoch,age都是0,在加锁的时候进行设置这些的值.
- 偏向锁默认是延迟的,不会在程序启动的时候立刻生效,如果想避免延迟,可以添加虚拟机参数来禁用延迟:-
XX:BiasedLockingStartupDelay=0
来禁用延迟 - 注意:处于偏向锁的对象解锁后,线程id仍存储于对象头中
- 加上虚拟机参数-XX:BiasedLockingStartupDelay=0进行测试
public static void main(String[] args) throws InterruptedException {
Test1 t = new Test1();
test.parseObjectHeader(getObjectHeader(t));
synchronized (t){
test.parseObjectHeader(getObjectHeader(t));
}
test.parseObjectHeader(getObjectHeader(t));
}
输出结果如下,三次输出的状态码都为101
biasedLockFlag (1bit): 1
LockFlag (2bit): 01
biasedLockFlag (1bit): 1
LockFlag (2bit): 01
biasedLockFlag (1bit): 1
LockFlag (2bit): 01
测试禁用:如果没有开启偏向锁,那么对象创建后最后三位的值为001,这时候它的hashcode,age都为0,hashcode是第一次用到hashcode
时才赋值的。在上面测试代码运行时在添加VM参数-XX:-UseBiasedLocking
禁用偏向锁(禁用偏向锁则优先使用轻量级锁),退出synchronized
状态变回001
- 测试代码虚拟机参数
-XX:-UseBiasedLocking
- 输出结果如下,最开始状态为001,然后加轻量级锁变成00,最后恢复成001
biasedLockFlag (1bit): 0
LockFlag (2bit): 01
LockFlag (2bit): 00
biasedLockFlag (1bit): 0
LockFlag (2bit): 01
撤销偏向锁-hashcode方法
测试hashCode
:当调用对象的hashcode方法的时候就会撤销这个对象的偏向锁,因为使用偏向锁时没有位置存hashcode
的值了
-
测试代码如下,使用虚拟机参数
-XX:BiasedLockingStartupDelay=0
,确保我们的程序最开始使用了偏向锁,但是结果显示程序还是使用了轻量级锁。public static void main(String[] args) throws InterruptedException { Test1 t = new Test1(); t.hashCode(); test.parseObjectHeader(getObjectHeader(t)); synchronized (t){ test.parseObjectHeader(getObjectHeader(t)); } test.parseObjectHeader(getObjectHeader(t)); }
-
输出结果
biasedLockFlag (1bit): 0 LockFlag (2bit): 01 LockFlag (2bit): 00 biasedLockFlag (1bit): 0 LockFlag (2bit): 01
撤销偏向锁-其它线程使用对象
这里我们演示的是偏向锁撤销变成轻量级锁的过程,那么就得满足轻量级锁的使用条件,就是没有线程对同一个对象进行锁竞争,我们使用wait
和notify
来辅助实现
-
代码虚拟机参数
-XX:BiasedLockingStartupDelay=0
确保我们的程序最开始使用了偏向锁public static void main(String[] args) throws InterruptedException { Dog d = new Dog(); new Thread(() -> { log.debug(ClassLayout.parseInstance(d).toPrintableSimple(withoutHex:ture)); synchronized (d) { log.debug(ClassLayout.parseInstance(d).toPrintableSimple(withoutHex:ture)); } log.debug(ClassLayout.parseInstance(d).toPrintableSimple(withoutHex:ture)); synchronized (TestBiased.class) { TestBiased.class.notify(); } }, name:"t1").start(); new Thread(() -> { synchronized (TestBiased.class) { try{ TestBiased.class.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } log.debug(ClassLayout.parseInstance(d).toPrintableSimple(withoutHex:ture)); synchronized (d) { log.debug(ClassLayout.parseInstance(d).toPrintableSimple(withoutHex:ture)); } log.debug(ClassLayout.parseInstance(d).toPrintableSimple(withoutHex:ture)); }, name:"t2").start(); }
-
输出结果,最开始使用的是偏向锁,但是第二个线程尝试获取对象锁时(第二个线程加锁时间和第一个线程交错开),发现本来对象偏向的是线程一,那么偏向锁就会失效,加的就是轻量级锁
biasedLockFlag (1bit): 1 LockFlag (2bit): 01 biasedLockFlag (1bit): 1 LockFlag (2bit): 01 biasedLockFlag (1bit): 1 LockFlag (2bit): 01 biasedLockFlag (1bit): 1 LockFlag (2bit): 01 LockFlag (2bit): 00 biasedLockFlag (1bit): 0 LockFlag (2bit): 01
撤销-调用wait/notify
会使对象的锁变成重量级锁,因为wait/notify方法之后重量级锁才支持
批量重偏向
如果对象被多个线程访问,但是没有竞争,这时候偏向了线程一的对象又有机会重新偏向线程二,即可以不用升级为轻量级锁,可这和我们之前做的实验矛盾了,其实要实现重新偏向是要有条件的:就是超过20对象对同一个线程如线程一撤销偏向时,那么第20个及以后的对象才可以将撤销对线程一的偏向这个动作变为将第20个及以后的对象偏向线程二。
批量撤销
当撤销偏向锁阈值超过40次后,jvm会觉得自己确实偏向错了,根本就不该偏向,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的.
1.5 wait和notify
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vyfBhJVb-1693292701853)(C:\Users\李茂森\AppData\Roaming\Typora\typora-user-images\image-20221003145515192.png)]
- Owner线程发现条件不满足,调用wait方法,即可进入WaitSet变为WAITING状态
- BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间片
- BLOCKED线程会在Owner线程释放锁时唤醒
- WAITING线程会在Owner线程调用notify或notifyAll时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入EntryList重新竞争
sleep(long n)和wait(long n)的区别
- sleep是Thread方法,而wait是Object的方法
- sleep不需要强制和synchronize配合使用,但wait需要和synchronize一起用
- sleep在睡眠的同时,不会释放对象锁的,但wait在等待的时候会释放对象锁
- 他们的状态都是
TIMED_WAITING
虚假唤醒演示
new Thread(() -> {
synchronized (room) {
log.debug("有烟没?[{}]", hasCigarette);
if (!hasCigarette) {
log.debug("没烟,先歇会!");
try {
room.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("有烟没?[{}]", hasCigarette);
if (hasCigarette) {
log.debug("可以开始干活了");
} else {
log.debug("没干成活...");
}
}
}, "小南").start();
new Thread(() -> {
synchronized (room) {
Thread thread = Thread.currentThread();
log.debug("外卖送到没?[{}]", hasTakeout);
if (!hasTakeout) {
log.debug("没外卖,先歇会!");
try {
room.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("外卖送到没?[{}]", hasTakeout);
if (hasTakeout) {
log.debug("可以开始干活了");
} else {
log.debug("没干成活...");
}
}
}, "小女").start();
sleep(1);
new Thread(() -> {
synchronized (room) {
hasTakeout = true;
log.debug("外卖到了噢!");
room.notify();
}
}, "送外卖的").start();
输出
20:53:12.173 [小南] c.TestCorrectPosture - 有烟没?[false]
20:53:12.176 [小南] c.TestCorrectPosture - 没烟,先歇会!
20:53:12.176 [小女] c.TestCorrectPosture - 外卖送到没?[false]
20:53:12.176 [小女] c.TestCorrectPosture - 没外卖,先歇会!
20:53:13.174 [送外卖的] c.TestCorrectPosture - 外卖到了噢!
20:53:13.174 [小南] c.TestCorrectPosture - 有烟没?[false]
20:53:13.174 [小南] c.TestCorrectPosture - 没干成活...
notify只能随机唤醒一个WaitSet中的线程,这时如果有其它线程也在等待,那么就可能唤醒不了正确的线程,称之为【虚假唤醒】;解决方法,改为notifyAll
改正之后
new Thread(() -> {
synchronized (room) {
hasTakeout = true;
log.debug("外卖到了噢!");
room.notifyAll();
}
}, "送外卖的").start();
输出
20:55:23.978 [小南] c.TestCorrectPosture - 有烟没?[false]
20:55:23.982 [小南] c.TestCorrectPosture - 没烟,先歇会!
20:55:23.982 [小女] c.TestCorrectPosture - 外卖送到没?[false]
20:55:23.982 [小女] c.TestCorrectPosture - 没外卖,先歇会!
20:55:24.979 [送外卖的] c.TestCorrectPosture - 外卖到了噢!
20:55:24.979 [小女] c.TestCorrectPosture - 外卖送到没?[true]
20:55:24.980 [小女] c.TestCorrectPosture - 可以开始干活了
20:55:24.980 [小南] c.TestCorrectPosture - 有烟没?[false]
20:55:24.980 [小南] c.TestCorrectPosture - 没干成活...
用notifyAll仅解决某个线程的唤醒问题,但使用if+wait判断仅有一次机会,一旦条件不成立,就没有重新判断的机会了,解决方法:用while+wait,当条件不成立,再次wait.
将if改为while
while (!hasCigarette) {
log.debug("没烟,先歇会!");
try {
room.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
输出
20:58:34.322 [小南] c.TestCorrectPosture - 有烟没?[false]
20:58:34.326 [小南] c.TestCorrectPosture - 没烟,先歇会!
20:58:34.326 [小女] c.TestCorrectPosture - 外卖送到没?[false]
20:58:34.326 [小女] c.TestCorrectPosture - 没外卖,先歇会!
20:58:35.323 [送外卖的] c.TestCorrectPosture - 外卖到了噢!
20:58:35.324 [小女] c.TestCorrectPosture - 外卖送到没?[true]
20:58:35.324 [小女] c.TestCorrectPosture - 可以开始干活了
20:58:35.324 [小南] c.TestCorrectPosture - 没烟,先歇会!
使用的正确姿势
synchronized(lock) {
while(条件不成立) {
lock.wait();
}
// 干活
}
//另一个线程
synchronized(lock) {
lock.notifyAll();
}
1.6 park&unpack
1.6.1 park unpark原理
每个线程都有自己的一个Parker对象,由三部分组成 _counter, _cond和 _mutex
- 打个比喻线程就像一个旅人,Parker就像他随身携带的背包,条件变量_cond就好比背包中的帐篷. _counter就好比背包中的备用干粮(0为耗尽,1为充足)
- 调用park就是要看需不需要停下来歇息
- 如果备用干粮耗尽,那么钻进帐篷歇息
- 如果备用干粮充足,那么不需停留,继续前进
- 调用unpark,就好比令干粮充足
- 如果这时线程还在帐篷,就唤醒让他继续前进
- 如果这时线程还在运行,那么下次他调用park时,仅是消耗掉备用干粮,不需停留继续前进
- 因为背包空间有限,多次调用unpark仅会补充一份备用干粮
先调用park再调用upark的过程
-
先调用park
-
当前线程调用Unsafe.park()方法
-
检查_counter,本情况为0,这时,获得 _mutex互斥锁(mutex对象有个等待队列 _cond)
-
线程进入_cond条件变量阻塞
-
设置_counter=0
-
-
调用upark
-
调用Unsafe.unpark(Thread_0)方法,设置_counter为1
-
唤醒_cond条件变量中的Thread_0
-
Thread_0恢复运行
-
设置_counter为0
-
先调用upark再调用park的过程
- 调用Unsafe.unpark(Thread_0)方法,设置_counter为1
- 当前线程调用Unsafe.park()方法
- 检查_counter,本情况为1,这时线程无需阻塞,继续运行
- 设置_counter为0
1.7 线程状态转换
-
NEW --> RUNNABLE
当调用t.start()方法时,由NEW --> RUNNABLE
-
RUNNABLE <–> WAITING
- 线程用synchronized(obj)获取了对象锁后
- 调用obj.wait()方法时,t线程从RUNNABLE --> WAITING
- 调用obj.notify(),obj.notifyAll(),t.interrupt()时
- 竞争锁成功,t线程从WAITING --> RUNNABLE
- 竞争锁失败,t线程从WAITING --> BLOCKED
- 线程用synchronized(obj)获取了对象锁后
-
RUNNABLE <–> WAITING
-
当前线程调用LockSupport.park()方法会让当前线程从RUNNABLE --> WAITING
-
调用LockSupport.unpark(目标线程)或调用了线程的interrupt(),会让目标线程从WAITING --> RUNNABLE
-
-
RUNNABLE <–> WAITING
-
当前线程调用t.join()方法时,当前线程从RUNNABLE --> WAITING,注意是当前线程在t线程对象的监视器上等待
-
t线程运行结束,或调用了当前线程的interrupt()时,当前线程从WAITING --> RUNNABLE
-
-
RUNNABLE <–> TIMED_WAITING
t线程用synchronized(obj)获取了对象锁后
-
调用obj.wait(long n)方法时,t线程从RUNNABLE --> TIMED_WAITING
-
t线程等待时间超过了n毫秒,或调用obj.notify(),obj.notifyAll(),t.interrupt()时
-
竞争锁成功,t线程从TIMED_WAITING --> RUNNABLE
-
竞争锁失败,t线程从TIMED_WAITING --> BLOCKED
-
-
-
RUNNABLE <–> TIMED_WAITING
-
当前线程调用t.join(long n)方法时,当前线程从RUNNABLE --> TIMED_WAITING注意是当前线程在t线程对象的监视器上等待
-
当前线程等待时间超过了n毫秒,或t线程运行结束,或调用了当前线程的interrupt()时,当前线程从TIMED_WAITING --> RUNNABLE
-
-
RUNNABLE <–> TIMED_WAITING
- 当前线程调用Thread.sleep(long n),当前线程从RUNNABLE --> TIMED_WAITING
- 当前线程等待时间超过了n毫秒或调用了线程的interrupt(),当前线程从TIMED_WAITING --> RUNNABLE
-
RUNNABLE <–> TIMED_WAITING
- 当前线程调用LockSupport.parkNanos(long nanos)或LockSupport.parkUntil(long millis)时,当前线程从 RUNNABLE --> TIMED_WAITING
- 调用LockSupport.unpark(目标线程)或调用了线程的interrupt(),或是等待超时,会让目标线程从TIMED_WAITING–> RUNNABLE
1.8 活跃性
活跃性相关的一系列问题都可以用ReentrantLock进行解决。
1.8.1 死锁
有这样的情况:一个线程需要同时获取多把锁,这时就容易发生死锁t1线程获得A对象锁,接下来想获取B对象的锁;t2线程获得B对象锁,接下来想获取A对象的锁。
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
synchronized (A) {
log.debug("lock A");
sleep(1);
synchronized (B) {
log.debug("lock B");
log.debug("操作...");
}
}
}, "t1");
Thread t2 = new Thread(() -> {
synchronized (B) {
log.debug("lock B");
sleep(0.5);
synchronized (A) {
log.debug("lock A");
log.debug("操作...");
}
}
}, "t2");
t1.start();
t2.start();
结果
12:22:06.962 [t2] c.TestDeadLock - lock B
12:22:06.962 [t1] c.TestDeadLock - lock A
1.8.2 定位死锁
定位死锁可以使用jconsole工具;或者使用jps定位进程id,再用jstack定位死锁
下面使用jstack工具进行演示
D:\我的项目\JavaLearing\java并发编程\jdk8>jps
1156 RemoteMavenServer36
20452 Test25
9156 Launcher
23544 Jps
23848
22748 Test28
D:\我的项目\JavaLearing\java并发编程\jdk8>jstack 22748
2020-07-12 18:54:44
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.211-b12 mixed mode):
"DestroyJavaVM" #14 prio=5 os_prio=0 tid=0x0000000002a03800 nid=0x5944 waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
//................省略了大部分内容.............//
Found one Java-level deadlock:
=============================
"线程二":
waiting to lock monitor 0x0000000002afc0e8 (object 0x00000000db9f76d0, a java.lang.Object),
which is held by "线程1"
"线程1":
waiting to lock monitor 0x0000000002afe1e8 (object 0x00000000db9f76e0, a java.lang.Object),
which is held by "线程二"
Java stack information for the threads listed above:
===================================================
"线程二":
at com.concurrent.test.Test28.lambda$main$1(Test28.java:39)
- waiting to lock <0x00000000db9f76d0> (a java.lang.Object)
- locked <0x00000000db9f76e0> (a java.lang.Object)
at com.concurrent.test.Test28$$Lambda$2/326549596.run(Unknown Source)
at java.lang.Thread.run(Thread.java:748)
"线程1":
at com.concurrent.test.Test28.lambda$main$0(Test28.java:23)
- waiting to lock <0x00000000db9f76e0> (a java.lang.Object)
- locked <0x00000000db9f76d0> (a java.lang.Object)
at com.concurrent.test.Test28$$Lambda$1/1343441044.run(Unknown Source)
at java.lang.Thread.run(Thread.java:748)
避免死锁要注意加锁顺序,另外如果由于某个线程进入了死循环,导致其它线程一直等待,对于这种情况linux下可以通过top先定位到CPU占用高的Java进程,再利用top -Hp进程id来定位是哪个线程,最后再用jstack排查.
1.8.3 哲学家就餐问题
有五位哲学家,围坐在圆桌旁。他们只做两件事,思考和吃饭,思考一会吃口饭,吃完饭后接着思考。吃饭时要用两根筷子吃,桌上共有5根筷子,每位哲学家左右手边各有一根筷子。如果筷子被身边的人拿着,自己就得等待.
当每个哲学家即线程持有一根筷子时,他们都在等待另一个线程释放锁,因此造成了死锁。这种线程没有按预期结束,执行不下去的情况,归类为【活跃性】问题,除了死锁以外,还有活锁和饥饿两种情况
活锁
活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,例如
public class TestLiveLock {
static volatile int count = 10;
static final Object lock = new Object();
public static void main(String[] args) {
new Thread(() -> {
// 期望减到 0 退出循环
while (count > 0) {
sleep(0.2);
count--;
log.debug("count: {}", count);
}
}, "t1").start();
new Thread(() -> {
// 期望超过 20 退出循环
while (count < 20) {
sleep(0.2);
count++;
log.debug("count: {}", count);
}
}, "t2").start();
}
}
饥饿
一个线程由于优先级太低,始终得不到CPU调度执行,也不能够结束,先来看看使用顺序加锁的方式解决之前的死锁问题,就是两个线程对两个不同的对象加锁的时候都使用相同的顺序进行加锁。但是会产生饥饿问题
顺序加锁的解决方案
1.9 ReentrantLock
相对于synchronized它具备如下特点
- 可中断
- 可以设置超时时间
- 可以设置为公平锁
- 支持多个条件变量,即对与不满足条件的线程可以放到不同的集合中等待
与synchronized一样,都支持可重入
基本语法
// 获取锁
reentrantLock.lock();
try {
// 临界区
} finally {
// 释放锁
reentrantLock.unlock();
}
可重入
可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁,如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住
static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
method1();
}
public static void method1() {
lock.lock();
try {
log.debug("execute method1");
method2();
} finally {
lock.unlock();
}
}
public static void method2() {
lock.lock();
try {
log.debug("execute method2");
method3();
} finally {
lock.unlock();
}
}
public static void method3() {
lock.lock();
try {
log.debug("execute method3");
} finally {
lock.unlock();
}
}
输出
17:59:11.862 [main] c.TestReentrant - execute method1
17:59:11.865 [main] c.TestReentrant - execute method2
17:59:11.865 [main] c.TestReentrant - execute method3
可打断
/**
* 测试使用lock.lock加锁时线程被打断时的效果
*/
public class Test31 {
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
new Thread(()->{
lock.lock();
try{
utils.sleep(20);
}finally {
lock.unlock();
}
}).start();
new Thread(()->{
lock.lock();
try{
utils.sleep(20);
}finally {
lock.unlock();
}
},"线程一").start();
new Thread(()->{
lock.lock();
try{
utils.sleep(20);
}finally {
lock.unlock();
}
},"线程一").start();
Thread t1 = new Thread(()->{
lock.lock();
try{
// utils.sleep(20);
System.out.println(1111);
}finally {
lock.unlock();
}
},"线程一");
t1.start();
t1.interrupt();
}
}
如果是不可打断模式,那么即使使用了interrupt也不会让等待中断
锁超时
public class Test32 {
public static void main(String[] args) {
Lock lock = new ReentrantLock();
Thread thread = new Thread(() -> {
try {
if (!lock.tryLock(2, TimeUnit.SECONDS)){
log.debug("获取等待指定时间后失败,返回");
// 这里如果出了错不要再往下执行了
return;
}
} catch (InterruptedException e) {
e.printStackTrace();
log.info("被打断啦");
// 这里如果出了错不要再往下执行了
return;
}
try{
log.info("执行完啦,获取到了锁,没被打断");
}finally {
lock.unlock();
}
}, "thread-1");
log.info("主线程获取");
lock.lock();
thread.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("主线程释放锁");
lock.unlock();
log.info("主线程执行结束");
}
}
使用锁超时解决哲学家就餐死锁问题
@Slf4j
/**
* 使用reentrantlock中的tryLock来获取锁来解决哲学家就餐问题,这样就不会造成死锁!
*/
public class Test33 extends Thread{
public static void main(String[] args) {
Chopstick2 c1 = new Chopstick2("1");
Chopstick2 c2 = new Chopstick2("2");
Chopstick2 c3 = new Chopstick2("3");
Chopstick2 c4 = new Chopstick2("4");
Chopstick2 c5 = new Chopstick2("5");
new Philosopher2("苏格拉底", c1, c2).start();
new Philosopher2("柏拉图", c2, c3).start();
new Philosopher2("亚里士多德", c3, c4).start();
new Philosopher2("赫拉克利特", c4, c5).start();
new Philosopher2("阿基米德", c5, c1).start();
}
}
@Slf4j(topic = "Philosopher")
class Philosopher2 extends Thread{
Chopstick2 left;
Chopstick2 right;
public Philosopher2(String name, Chopstick2 left, Chopstick2 right) {
super(name);
this.left = left;
this.right = right;
}
private void eat() {
log.debug("eating...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
@Override
public void run() {
while (true) {
try {
if (left.tryLock(2, TimeUnit.SECONDS)){
try {
if (right.tryLock(2, TimeUnit.SECONDS)){
try {
eat();
}finally {
right.unlock();
}
}
}finally {
left.unlock();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class Chopstick2 extends ReentrantLock {
private String name ;
public Chopstick2(String name) {
this.name = name;
}
@Override
public String toString() {
return "Chopstick{" +
"name='" + name + '\'' +
'}';
}
}
公平锁
synchronized锁中,在entrylist等待的锁在竞争时不是按照先到先得来获取锁的,所以说synchronized锁时不公平的;ReentranLock锁默认是不公平的,但是可以通过设置实现公平锁。本意是为了解决之前提到的饥饿问题,但是公平锁一般没有必要,会降低并发度,使用trylock也可以实现。
条件变量
synchronized中也有条件变量,就是我们讲原理时那个waitSet休息室,当条件不满足时进入waitSet等待,ReentrantLock的条件变量比synchronized强大之处在于,它是支持多个条件变量的,这就好比
- synchronized是那些不满足条件的线程都在一间休息室等消息
- 而ReentrantLock支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒
使用要点:
- await前需要获得锁
- await执行后,会释放锁,进入conditionObject等待
- await的线程被唤醒(或打断、或超时)取重新竞争lock锁,执行唤醒的线程也必须先获得锁
- 竞争lock锁成功后,从await后继续执行
static ReentrantLock lock = new ReentrantLock();
static Condition waitCigaretteQueue = lock.newCondition();
static Condition waitbreakfastQueue = lock.newCondition();
static volatile boolean hasCigrette = false;
static volatile boolean hasBreakfast = false;
public static void main(String[] args) {
new Thread(() -> {
try {
lock.lock();
while (!hasCigrette) {
try {
waitCigaretteQueue.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("等到了它的烟");
} finally {
lock.unlock();
}
}).start();
new Thread(() -> {
try {
lock.lock();
while (!hasBreakfast) {
try {
waitbreakfastQueue.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("等到了它的早餐");
} finally {
lock.unlock();
}
}).start();
sleep(1);
sendBreakfast();
sleep(1);
sendCigarette();
}
private static void sendCigarette() {
lock.lock();
try {
log.debug("送烟来了");
hasCigrette = true;
waitCigaretteQueue.signal();
} finally {
lock.unlock();
}
}
private static void sendBreakfast() {
lock.lock();
try {
log.debug("送早餐来了");
hasBreakfast = true;
waitbreakfastQueue.signal();
} finally {
lock.unlock();
}
}
输出
18:52:27.680 [main] c.TestCondition - 送早餐来了
18:52:27.682 [Thread-1] c.TestCondition - 等到了它的早餐
18:52:28.683 [main] c.TestCondition - 送烟来了
18:52:28.683 [Thread-0] c.TestCondition - 等到了它的烟
文章小结
本章我们需要重点掌握的是
- 分析多线程访问共享资源时,哪些代码片段属于临界区
- 使用synchronized互斥解决临界区的线程安全问题
- 掌握synchronized锁对象语法
- 掌握synchronzied加载成员方法和静态方法语法
- 掌握wait/notify同步方法
- 使用lock互斥解决临界区的线程安全问题 掌握lock的使用细节:可打断、锁超时、公平锁、条件变量
- 学会分析变量的线程安全性、掌握常见线程安全类的使用
- 了解线程活跃性问题:死锁、活锁、饥饿
- 应用方面
- 互斥:使用synchronized或Lock达到共享资源互斥效果,实现原子性效果,保证线程安全。
- 同步:使用wait/notify或Lock的条件变量来达到线程间通信效果。
- 原理方面
- monitor、synchronized、wait/notify原理
- synchronized进阶原理
- park & unpark原理