Java多线程 - Synchronized

线程安全

使用多线程可以提高程序的运行效率,同时来带来了一些额外的问题。

 private static int counter = 0;
 ​
 // t1和t2线程分别对静态变量counter进行自增和自减
 public static void main(String[] args) throws InterruptedException {
     Thread t1 = new Thread(() -> {
         for (int i = 0; i < 5000; i++) {
             counter++;
         }
     }, "t1");
     Thread t2 = new Thread(() -> {
         for (int i = 0; i < 5000; i++) {
             counter--;
         }
     }, "t2");
     t1.start();
     t2.start();
     t1.join();
     t2.join();
     log.debug("{}", counter);
 }
复制代码

上述代码运行完打印的结果我们预期是0,但最终可能不是0,而是正数,负数,0都有可能。这就说明多线程读写共享变量会有多线程问题,下面我们具体分析一下。

分析

在java中,自增和自减不是原子性操作,分解成字节码如下:

 // i++
 getstatic i // 获取静态变量i的值
 iconst_1 // 准备常量1
 iadd // 自增
 putstatic i // 将修改后的值存入静态变量i
     
 // i--
 getstatic i // 获取静态变量i的值
 iconst_1 // 准备常量1
 isub // 自减
 putstatic i // 将修改后的值存入静态变量i
复制代码

先说一下Java内存模型,其存在一个主内存,每个线程读数据时会把主内存的数据复制到自己的工作内存操作,写数据时会把数据写到主内存。如下:

image.png

接下来通过时序图找找出错的原因:

image.png 我们发现,因为自增和自减不是原子性操作,所以有可能在任何一步指令操作都会发生上下文切换,这就是导致出错的原因。

当多个线程访问共享资源时:

  • 都是读操作时不会有线程安全问题
  • 读写时,如果不是原子性操作则会有线程安全问题
  • 产生线程安全问题的代码块,称之为 临界区

synchronized 解决方案

synchronized是Java中的关键字,表示同步代码块,其可以保证临界区的代码块保持原子性。如下所示:

 private static int counter = 0;
 private static final Object lock = new Object();
 ​
 // t1和t2线程分别对静态变量counter进行自增和自减
 public static void main(String[] args) throws InterruptedException {
     Thread t1 = new Thread(() -> {
         for (int i = 0; i < 5000; i++) {
             synchronized(lock){
                 counter++;
             }
         }
     }, "t1");
     Thread t2 = new Thread(() -> {
         for (int i = 0; i < 5000; i++) {
             synchronized(lock){
                 counter--;
             }
         }
     }, "t2");
     t1.start();
     t2.start();
     t1.join();
     t2.join();
     log.debug("{}", counter);
 }
复制代码

上述执行过程参考如下时序图:

image.png

  • 使用 synchronized 监视同一个对象锁才有意义
  • synchronized 可以定义在成员方法上,其监视的对象锁是 this
  • synchronized 可以定义在静态方法上,其监视的对象锁是当前类对象(如 Test.class)

哪些情况需要处理线程安全

  1. 成员变量和静态变量

    当成员变量和静态变量被多个线程读写时,会有线程安全问题。只读不会有安全问题。

  2. 局部变量

    局部变量中的基本类型和引用类型变量大多都是线程安全的,但有例外(应该不会有人这么写代码吧),如下:

     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/public void method3(ArrayList<String> list) {
             // list.remove(0);
             new Thread(() -> {
                 list.remove(0);
             }).start();
         }
     }
     ​
     class ThreadSafeSubClass extends ThreadSafe {
         // 重写新起一个线程来读写
         @Override
         public void method3(ArrayList<String> list) {
             new Thread(() -> {
                 list.remove(0);
             }).start();
         }
     }
    复制代码

synchronized原理

对象头

java中的对象包含一个对象头,其主要保存Mark Wordklass Word两部分,数组额外多一个array length。 其中Mark Word含有锁的信息,结构为

image.png Normal(正常状态),Biased(偏向锁状态),Lightweight Locaked(轻量级锁状态),Heavyweight Locked(重量级锁状态),Marked for GC(垃圾回收状态)

Monitor(管程)

操作系统在面对 进程/线程 间同步使用 semaphore(信号量)和 mutex(互斥量)同步原语。程序员直接使用其时,非常不方便并且容易出错。所以在 semaphore 和 mutex 的基础上,提出了更高层次的同步原语 monitor。

操作系统本身并不支持 monitor 机制,实际上,monitor 是属于编程语言的范畴,当你想要使用 monitor 时,先了解一下语言本身是否支持 monitor 原语,例如 C 语言它就不支持 monitor,Java 语言支持 monitor

Monitor包含了Owner(指属于哪个线程),EntryList(指未获取锁阻塞的线程列表),WaitSet(指未满足条件,需要等待的线程列表,如调用了 wait() 的线程)等信息。

重量级锁加锁流程

当 t2线程 使用synchronized给锁对象上锁时,锁对象的 Mark Word (状态变成Heavyweight Locked - 重量级锁状态)会指向一个 Monitor, 其 Owner 指向了 t2线程。因Owner已有指向,当 t3,t4线程 想再来获取锁时,会进入 EntryList 列表阻塞等待,当 t2线程 执行完后会释放锁并唤醒 t3,t4线程 来竞争锁。

image.png 如果线程可能需要某种条件才能继续执行,如 t1线程,其可能会调用类似 wait() 的手段来等待条件,这时线程就会进入 WaitSet 等待并且释放锁,当条件满足后被唤醒(如 notifyAll() ),然后进入 EntryList 来竞争锁。

上述讲的就是 synchronized 重量级锁的加锁过程,见名知意,重量级锁的性能比较差。所以就有了轻量级锁来优化。

轻量级锁加锁流程

如果一个锁对象被多个线程要加锁,但加锁的时间是错开的(没有竞争),那么就可以使用轻量级锁来优化。

当线程使用 synchronized 对锁对象加锁时,其栈帧中会创建一个锁记录(Lock Record)区域,其包含锁记录的地址信息和对象引用,对象引用指向锁对象,其尝试使用 CAS(比较并交换)机制来替换锁对象的 Mark Word(状态改为 Lightweight Locaked - 轻量级锁状态)为锁记录地址信息,锁记录的地址信息则换为锁对象的 Mark Word。当 CAS 替换成功,表示由线程持有轻量级锁。

image.png 当 CAS 替换失败,有两种情况,一是已经被其它线程持有锁,表示有竞争了,那么会升级为 重量级锁流程;

  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁

image.png

  • 这时 Thread-1 加轻量级锁失败,进入 重量级锁流程

image.png 二是锁重入,那么再加一条锁记录,其地址信息为空,对象引用也指向锁对象。

image.png 当解锁时,如果发现锁记录的地址信息为空,那么会删除这条锁记录。 如果锁记录的地址信息不为空,则使用 CAS 将锁对象的 Mark Word 恢复,成功则解锁成功;失败则说明已经进行了锁升级那么进入重量级锁解锁流程。

轻量级锁在没有竞争时,每次重入还是需要 CAS 操作,其实没有必要,那么就有了偏向锁的优化。

偏向锁加锁流程

当线程对锁对象加锁时,第一次使用 CAS 操作将线程ID设置到锁对象的 Mark Word中(状态改为Biased - 偏向锁状态),后续如果发现还是这个线程来加锁,那么就不用 CAS 操作。如果有别的线程来加锁,那么会升级为轻量级锁。

  • 偏向锁默认开启,但是延迟的,大概程序运行2-3秒后正式开启。可以使用参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟
  • 当调用锁对象的 hashCode() 时,会撤销偏向锁,因为偏向锁状态的 Mark Word没有存储 hashCode的区域。轻量级锁会在锁记录中记录 hashCode,重量级锁会在 Monitor中记录 hashCode
  • 当使用类似 wait()/notify() 时,会撤销偏向锁和轻量级锁,因为其机制只有在重量级锁中有实现
  • 偏向锁不会主动解锁释放,偏向锁重偏向一次之后不可再次重偏向。批量重偏向和批量撤销是针对类的优化,和对象无关。

锁升级流程总结

一个对象A刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程T1来访问它的时候,它会偏向T1,此时,对象A持有偏向锁。

T1在修改对象头成为偏向锁的时候使用 CAS 操作,并将对象头中的 ThreadID 改成自己的ID,之后再次访问这个对象时,只需要对比 ID,不需要再使用 CAS 在进行操作。

一旦有第二个线程T2访问这个对象,因为偏向锁不会主动释放,所以T2可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了。检查原来持有该对象锁的线程T1是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁, 则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象恢复成无锁状态,然后重新偏向。

轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下,即自旋,另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。

常见线程安全的类

String,Integer,StringBuffer,Random,Vector,Hashtable,juc包下的类

  • String,Integer等保证线程安全的原因是不可变的,虽然其有改变值的方法,但其实都是新的对象。
  • StringBuffer,Random,Vector,Hashtable等都是使用 synchronized来保证线程安全,但只是单个方法线程安全,方法组合使用还是不安全的。

猜你喜欢

转载自juejin.im/post/7078128095206588447