并发编程(六)-分析synchronized如何保证线程安全

一、共享带来的问题

1. 小故事

  • 老王(操作系统)有一个功能强大的算盘(CPU),现在想把它租出去,赚一点外快
    在这里插入图片描述
  • 小南、小女(线程)使用这个算盘来进行一些计算,并按照时间给老王支付费用
  • 但小南不能一天24小时使用算盘,他经常要小憩一会(sleep),又或是去吃饭上厕所(阻塞 io 操作),有时还需要一根烟,没烟时思路全无(wait)这些情况统称为(阻塞)
    在这里插入图片描述
  • 在这些时候,算盘没利用起来(不能收钱了),老王觉得有点不划算
  • 另外,小女也想用用算盘,如果总是小南占着算盘,让小女觉得不公平
  • 于是,老王灵机一动,想了个办法让他们每人用一会,轮流使用算盘
  • 这样,当小南阻塞的时候,算盘可以分给小女使用,不会浪费,反之亦然
  • 最近执行的计算比较复杂,需要存储一些中间结果,而学生们的脑容量(工作内存)不够,所以老王申请了一个笔记本(主内存),把一些中间结果先记在本上
  • 计算流程是这样的
    在这里插入图片描述
  • 但是由于分时系统,有一天还是发生了事故
  • 小南刚读取了初始值 0 做了个 +1 运算,还没来得及写回结果
  • 老王说 [ 小南,你的时间到了,该别人了,记住结果走吧 ],于是小南念叨着 [ 结果是1,结果是1…] 不甘心地到一边待着去了(上下文切换)
  • 老王说 [ 小女,该你了 ],小女看到了笔记本上还写着 0 做了一个 -1 运算,将结果 -1 写入笔记本
  • 这时小女的时间也用完了,老王又叫醒了小南:[小南,把你上次的题目算完吧],小南将他脑海中的结果 1 写入了笔记本
  • 小南和小女都觉得自己没做错,但笔记本里的结果是 1 而不是 0

java实现

  • 两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 10000 次,结果是 0 吗?
@Slf4j(topic = "c.Test17_1")
public class Test17_1 {
    static int counter = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter++;
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                counter--;
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.debug("{}",counter);
    }
}
18:01:04.759 c.Test17_1 [main] - 3091

问题分析

  • 以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析
  • 例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
  • 对应 i-- 也是类似:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i
  • Java 的内存模型如下,完成静态变量的自增,自减需要在主内存工作内存中进行数据交换:
    在这里插入图片描述
  • 如果是单线程,以上的 8 行指令是顺序执行(不会交错)没有问题:
    在这里插入图片描述
  • 但多线程下,这 8 行指令可能交错运行:
  • 出现负数的情况:
    在这里插入图片描述
  • 出现正数的情况:
    在这里插入图片描述

2. 临界区 Critical Section

临界区(Critical Section)是指程序中对于一个共享数据,只能有一个线程可以进入该数据进行操作的区域。

临界区中的代码块要求是原子性的,因为多个线程同时访问共享数据时会出现冲突,因此为了保证数据一致性,必须要保证在任意时刻只有一个线程访问共享数据。因此需要对临界区的访问加以保护,确保其在任何时候都只能由一个线程访问。

例如,下面代码中的临界区

static int counter = 0;
static void increment()
// 临界区
{
	counter++;
}
static void decrement()
// 临界区
{
	counter--;
}

3. 竞态条件 Race Condition

竞态条件是指多个线程对共享数据的并发访问造成的不确定的结果。

在竞态条件下,由于线程的交替执行和对共享数据的并发访问,导致程序的执行结果不确定。竞态条件常常出现在多线程的环境下,可能导致程序运行的不稳定,严重影响程序的可靠性和安全性。因此,在设计多线程程序时,要采取措施避免或解决竞态条件。

二、synchronized 解决方案

为了避免临界区的竞态条件发生,有多种手段可以达到目的。

  • 阻塞式的解决方案:synchronized,Lock
  • 非阻塞式的解决方案:原子变量

1. synchronized 解决临界区的竞态条件发生

  • 本次使用阻塞式的解决方案:synchronized,来解决上述问题,即俗称的【对象锁】。
  • 它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。
  • 这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。

2. synchronized 的同步与互斥

  1. 虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:

    互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码。
    同步是由于线程执行的先后顺序不同,需要一个线程等待其它线程运行到某个点。

  2. 同步与互斥的应用
    互斥:使用 synchronized 或 ReentrantLock 来达到共享资源互斥效果
    同步:使用 wait/notify 或 ReentrantLock 的条件变量 来达到线程间通信效果

3. synchronized 语法

  • synchronized 加在普通方法中时,锁住的是 this 对象
// 当线程1获取到锁后, 线程2会阻塞住(BLOCKED)
class Test{
	public synchronized void test() {
		临界区
	}
}
等价于
class Test{
	public void test() {
		// synchronized 加在普通方法中时,锁住的是 this 对象
		synchronized(this) {
			临界区
		}
	}
}
  • synchronized 加在静态方法中时,锁住的是 类对象
// 当线程1获取到锁后, 线程2会阻塞住(BLOCKED)
class Test{
	public synchronized static void test() {
		临界区
	}
}
等价于
class Test{
	public static void test() {
		// synchronized 加在普通方法中时,锁住的是 类对象
		synchronized(Test.class) {
			临界区
		}
	}
}

synchronized 解决方案代码

@Slf4j(topic = "c.Test17_1")
public class Test17_1 {

    static int counter = 0;
    // 锁对象
    static Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                synchronized (lock) {
                    counter++;
                }
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                synchronized (lock) {
                    counter--;
                }
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.debug("{}",counter);
    }
}
21:12:00.343 c.Test17_1 [main] - 0

4. synchronized 的类比理解

在这里插入图片描述

  • synchronized(对象锁) 中的对象锁,可以想象为一个房间(room),有唯一入口(门),房间一次只能进入一人进行计算,线程 t1,t2 想象成两个人
  • 当线程 t1 执行到 synchronized(room) 时就好比 t1 进入了这个房间,并锁住了门拿走了钥匙,在门内执行 count++ 代码
  • 这时候如果 t2 也运行到了 synchronized(room) 时,它发现门被锁住了,只能在门外等待,发生了上下文切换,阻塞住了
  • 这中间即使 t1 的 cpu 时间片不幸用完,被踢出了门外(不要错误理解为锁住了对象就能一直执行下去),这时门还是锁住的,t1 仍拿着钥匙,t2 线程还在阻塞状态进不来,只有下次轮到 t1 自己再次获得时间片时才能开门进入
  • 当 t1 执行完 synchronized{} 块内的代码,这时候才会从 房间(room)出来并解开门上的锁,唤醒 t2 线程把钥匙给他。t2 线程这时才可以进入 房间(room),锁住门,执行它的 count-- 代码

5. synchronized 加锁后的时序图

  • synchronized 实际是用【对象锁】保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。
    在这里插入图片描述

6. 面向对象思想改进 synchronized 加锁

@Slf4j(topic = "c.Test17")
public class Test17 {
    public static void main(String[] args) throws InterruptedException {
        Room room = new Room();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                room.increment();
            }
        }, "t1");

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                room.decrement();
            }
        }, "t2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.debug("{}", room.getCounter());
    }
}

// 把需要保护的共享变量放入一个类 
class Room {
    private int counter = 0;

    public synchronized void increment() {
        counter++;
    }

    public synchronized void decrement() {
        counter--;
    }

    public synchronized int getCounter() {
        return counter;
    }
}
21:43:22.876 c.Test17 [main] - 0

7. synchronized 之 “线程八锁”

  • 其实就是考察 synchronized 锁住的是哪个对象

1. 不同线程 锁 同一个 this 对象(n1)

  • 打印结果:
    • 当t1先抢到锁:先1后2
    • 当t2先抢到锁:先2后1
@Slf4j(topic = "c.Test8Locks")
public class Test8Locks {
    public static void main(String[] args) {
        Number n1 = new Number();
        new Thread(() -> {
            log.debug("begin");
            n1.a();
        },"t1").start();
        new Thread(() -> {
            log.debug("begin");
            n1.b();
        },"t2").start();
    }
}
@Slf4j(topic = "c.Number")
class Number{
    public synchronized void a() {   
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}

2. 不同线程 锁 同一个 this 对象(n1),并且其中一个方法加 sleep

  • 打印结果:
    • 当t1先抢到锁:1s 后打印1、2
    • 当t2先抢到锁:先打印2,再1s 后打印1
@Slf4j(topic = "c.Test8Locks")
public class Test8Locks {
    public static void main(String[] args) {
        Number n1 = new Number();
        new Thread(() -> {
            log.debug("begin");
            n1.a();
        }, "t1").start();
        new Thread(() -> {
            log.debug("begin");
            n1.b();
        },"t2").start();
    }
}
@Slf4j(topic = "c.Number")
class Number{
    public synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}
案例1,2
小结1:对普通方法加synchronized时,锁住的是同一个this对象,谁先抢到锁,其他线程必须等它执行完并释放锁,才能接着执行

3. 不同线程 锁 同一个 类对象(Test8Locks .class),并且其中一个方法加 sleep

  • 打印结果:
    • 当t1先抢到锁:1s 后打印1、2
    • 当t2先抢到锁:先打印2,再1s 后打印1
@Slf4j(topic = "c.Test8Locks")
public class Test8Locks {
    public static void main(String[] args) {
        Number n1 = new Number();
        new Thread(() -> {
            log.debug("begin");
            n1.a();
        },"t1").start();
        new Thread(() -> {
            log.debug("begin");
            n1.b();
        },"t2").start();
    }
}
@Slf4j(topic = "c.Number")
class Number{
	// 加了static,锁的是 类对象 
    public static synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    // 加了static,锁的是 类对象 
    public static synchronized void b() {
        log.debug("2");
    }
}

4. 不同线程 锁 同一个 类对象(Test8Locks .class),并且其中一个方法加 sleep,再多创建一个 n2 对象

  • 打印结果:
    • 当t1先抢到锁:1s 后打印1、2
    • 当t2先抢到锁:先打印2,再1s 后打印1
@Slf4j(topic = "c.Test8Locks")
public class Test8Locks {
    public static void main(String[] args) {
        Number n1 = new Number();
        // 再多创建一个 n2 对象
        Number n2 = new Number();
        new Thread(() -> {
            log.debug("begin");
            // 用n1对象调用a方法
            n1.a();
        },"t1").start();
        new Thread(() -> {
            log.debug("begin");
            // 用n2对象调用b方法
            n2.b();
        },"t2").start();
    }
}
@Slf4j(topic = "c.Number")
class Number{
	// 加了static,锁的是 类对象
    public static synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    // 加了static,锁的是 类对象
    public static synchronized void b() {
        log.debug("2");
    }
}
案例3,4
小结2:
对静态方法加synchronized时,锁的是当前类的Class对象,如Number.class类模板,谁先抢到锁,其他线程必须等它执行完并释放锁,才能接着执行
此时跟this对象无关,就算是不同对象调用方法也没用

5. 不同线程 锁 不同对象(2个普通对象 n1 、n2),并且其中一个方法加 sleep

  • 打印结果:
    • 先打印2,1s 后打印1
@Slf4j(topic = "c.Test8Locks")
public class Test8Locks {
    public static void main(String[] args) {
        Number n1 = new Number();
        Number n2 = new Number();
        new Thread(() -> {
            log.debug("begin");
            n1.a();
        }).start();
        new Thread(() -> {
            log.debug("begin");
            n2.b();
        }).start();
    }
}
@Slf4j(topic = "c.Number")
class Number{
    public synchronized void a() {  
    	sleep(1);     
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}

6. 不同线程 锁 不同对象(1个普通对象 n1,1个静态对象 Test8Locks .class),并且其中一个方法加 sleep

  • 打印结果:
    • 先打印2,1s 后打印1
@Slf4j(topic = "c.Test8Locks")
public class Test8Locks {
    public static void main(String[] args) {
        Number n1 = new Number();
        new Thread(() -> {
            log.debug("begin");
            n1.a();
        }).start();
        new Thread(() -> {
            log.debug("begin");
            n1.b();
        }).start();
    }
}
@Slf4j(topic = "c.Number")
class Number{
    public static synchronized void a() {  
    	sleep(1);     
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}

7. 不同线程 锁 不同对象(1个普通对象 n1,1个静态对象 Test8Locks .class),并且其中一个方法加 sleep,再多创建一个 n2 对象

  • 打印结果:
    • 先打印2,1s 后打印1
@Slf4j(topic = "c.Test8Locks")
public class Test8Locks {
    public static void main(String[] args) {
        Number n1 = new Number();
        Number n2 = new Number();
        new Thread(() -> {
            log.debug("begin");
            n1.a();
        }).start();
        new Thread(() -> {
            log.debug("begin");
            n2.b();
        }).start();
    }
}
@Slf4j(topic = "c.Number")
class Number{
    public static synchronized void a() {  
    	sleep(1);     
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}
案例5,6,7
小结3:当线程使用不同锁对象时,则线程之间互不影响,按各自逻辑执行

8. 不同线程 锁 同一个 this 对象,并且其中一个方法加 sleep,再加一个没有加 synchronized 的普通方法

  • 打印结果:
    • 先打印3,1s 后打印1、2
    • 先打印3、2 ,再1s 后打印1
    • 先打印2、3, 再1s 后打印1
@Slf4j(topic = "c.Test8Locks")
public class Test8Locks {
    public static void main(String[] args) {
        Number n1 = new Number();
        new Thread(() -> {
            log.debug("begin");
            n1.a();
        }).start();
        new Thread(() -> {
            log.debug("begin");
            n1.b();
        }).start();
        new Thread(() -> {
            log.debug("begin");
            n1.c();
        }).start();
    }
}
@Slf4j(topic = "c.Number")
class Number{
    public synchronized void a() {  
    	sleep(1);     
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
    // 没有加 synchronized 的普通方法
    public void c() {
        log.debug("3");
    }
}
案例8
小结4:
当使用同一个锁对象时,先抢到锁先执行
当使用不同锁对象时,则线程之间互不影响,按各自逻辑执行

阿里巴巴开发手册建议:

在这里插入图片描述

三、从字节码层面理解 synchronized

1. synchronized加在同步代码块上

static final Object lock = new Object();
static int i = 0;
public static void main(String[] args) {
 	synchronized (lock) {
		 i++;
 	}
}

synchronized加在同步代码块上对应的字节码为

public static void main(java.lang.String[]);
 descriptor: ([Ljava/lang/String;)V
 flags: ACC_PUBLIC, ACC_STATIC
 Code:
 stack=2, locals=3, args_size=1
 0: getstatic #2 // <- 获取lock引用地址 
 3: dup			 // 复制一份存到临时变量 slot 1,为了后面解锁时用
 4: astore_1 // lock引用 -> slot 1
 5: monitorenter // 执行到synchronized, 将 lock对象 MarkWord 置为 Monitor 指针(hashcode:25 | age:4 | biased_lock:0 -> ptr_to_heavyweight_monitor:30)
 6: getstatic #3 // <- 获取i
 9: iconst_1 // 准备常数 1
 10: iadd // +1
 11: putstatic #3 // -> i
 14: aload_1 // <- 从临时变量 slot 1 得到 lock引用,找到 Monitor 
 15: monitorexit // 将 lock对象 MarkWord 重置(ptr_to_heavyweight_monitor:30 -> hashcode:25 | age:4 | biased_lock:0), 唤醒 EntryList
 16: goto 24
 19: astore_2 // 异常对象e 存到 slot 2 
 20: aload_1 // <- 获取lock引用
 21: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
 22: aload_2 // <- 从 slot 2 获取 异常对象e
 23: athrow // throw e 抛出异常
 24: return
 Exception table:
 from to target type
 6 16 19 any   // 监测可能出现异常的范围 6 ~ 16(同步代码块中的内容),出现异常后跳到 19 
 19 22 19 any  // 监测可能出现异常的范围 19 ~ 22
 LineNumberTable:
 line 8: 0
 line 9: 6
 line 10: 14
 line 11: 24
 LocalVariableTable:
 Start Length Slot Name Signature
 0 25 0 args [Ljava/lang/String;
 StackMapTable: number_of_entries = 2
 frame_type = 255 /* full_frame */
 offset_delta = 19
 locals = [ class "[Ljava/lang/String;", class java/lang/Object ]
 stack = [ class java/lang/Throwable ]
 frame_type = 250 /* chop */
 offset_delta = 4
  • 一般情况,synchronized字节码中1个monitorenter对应2个monitorexit
  • 例外情况,synchronized字节码中1个monitorenter对应1个monitorexit,如下图所示:
    在这里插入图片描述

2. synchronized加在普通方法上

  • synchronized加在普通方法上对应的字节码,只是加1个标识: ACC_SYNCHRONIZED
    在这里插入图片描述
调用指令将会检查方法的ACC_SYNCHRONIZAED访问标志是否被设置
若设置了,执行线程会将先持有monitor锁,然后在再执行方法,最后在方法完成时释放monitor(无论正常完成还是非正常完成)

3. synchronized加在静态方法上

  • synchronized加在静态方法上对应的字节码,只是加2个标识: ACC_SYNCHRONIZED、ACC_STATIC
    在这里插入图片描述
  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值