乐趣区

详解Java多线程锁之synchronized

synchronized 是 Java 中解决并发问题的一种最常用的方法,也是最简单的一种方法。

synchronized 的四种使用方式

  1. 修饰代码块:被修饰的代码块称为同步语句块,其作用的范围是大括号 {} 括起来的代码,作用于调用对象
  2. 修饰方法:被修饰的方法称为同步方法,其作用的范围是整个方法,作用于调用对象

注意:synchronized 修饰方法时必须是显式调用,如果没有显式调用,例如子类重写该方法时没有显式加上 synchronized,则不会有加锁效果。

  1. 修饰静态方法:其作用的范围是整个静态方法,作用于所有对象
  2. 修饰类:其作用的范围是 synchronized 后面括号括起来的部分(例如:test.class),作用于所有对象

对象锁和类锁是否会互相影响么?

  • 对象锁:Java 的所有对象都含有 1 个互斥锁,这个锁由 JVM 自动获取和释放。线程进入 synchronized 方法的时候获取该对象的锁,当然如果已经有线程获取了这个对象的锁,那么当前线程会等待;synchronized 方法正常返回或者抛异常而终止,JVM 会自动释放对象锁。这里也体现了用 synchronized 来加锁的 1 个好处,方法抛异常的时候,锁仍然可以由 JVM 来自动释放。
  • 类锁:对象锁是用来控制实例方法之间的同步,类锁是用来控制静态方法(或静态变量互斥体)之间的同步。其实类锁只是一个概念上的东西,并不是真实存在的,它只是用来帮助我们理解锁定实例方法和静态方法的区别的。java 类可能会有很多个对象,但是只有 1 个 Class 对象,也就是说类的不同实例之间共享该类的 Class 对象。Class 对象其实也仅仅是 1 个 java 对象,只不过有点特殊而已。由于每个 java 对象都有 1 个互斥锁,而类的静态方法是需要 Class 对象。所以所谓的类锁,不过是 Class 对象的锁而已。

类锁和对象锁不是同 1 个东西,一个是类的 Class 对象的锁,一个是类的实例的锁。也就是说:1 个线程访问静态 synchronized 的时候,允许另一个线程访问对象的实例 synchronized 方法。反过来也是成立的,因为他们需要的锁是不同的。

对应的实验代码如下:

@Slf4j
public class SynchronizedExample {

    // 修饰一个代码块
    public void test1(int j) {synchronized (this) {for (int i = 0; i < 10; i++) {log.info("test1 {} - {}", j, i);
            }
        }
    }

    // 修饰一个方法
    public synchronized void test2(int j) {for (int i = 0; i < 10; i++) {log.info("test2 {} - {}", j, i);
        }
    }

    // 修饰一个类
    public static void test3(int j) {synchronized (SynchronizedExample.class) {for (int i = 0; i < 10; i++) {log.info("test3 {} - {}", j, i);
            }
        }
    }

    // 修饰一个静态方法
    public static synchronized void test4(int j) {for (int i = 0; i < 10; i++) {log.info("test4 {} - {}", j, i);
        }
    }

    public static void main(String[] args) {SynchronizedExample example1 = new SynchronizedExample();
        SynchronizedExample example2 = new SynchronizedExample();
        ExecutorService executorService = Executors.newCachedThreadPool();
        executorService.execute(() -> {example1.test2(1);
        });
        executorService.execute(() -> {example2.test2(2);
        });
    }
}

在 JDK1.6 之前,synchronized 一直被称呼为重量级锁(重量级锁就是采用互斥量来控制对资源的访问)。通过反编译成字节码指令可以看到,synchronized 会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令。根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加 1,相应的,在执行 monitorexit 指令时会将锁计算器减 1,当计数器为 0 时,锁就被释放,然后 notify 通知所有等待的线程。
Java 的线程是映射到操作系统的原生线程上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要用户态和内核态切换,大量的状态转换需要耗费很多处理器的时间。

synchronized 的优化

在 JDK1.6 中对锁的实现引入了大量的优化:

  1. 锁粗化(Lock Coarsening):将多个连续的锁扩展成一个范围更大的锁,用以减少频繁互斥同步导致的性能损耗。
  2. 锁消除(Lock Elimination):JVM 即时编译器在运行时,通过逃逸分析,如果判断一段代码中,堆上的所有数据不会逃逸出去从来被其他线程访问到,就可以去除这个锁。
  3. 偏向锁(Biased Locking):目的是消除数据无竞争情况下的同步原语。使用 CAS 记录获取它的线程。下一次同一个线程进入则偏向该线程,无需任何同步操作。
  4. 适应性自旋(Adaptive Spinning):为了避免线程频繁挂起、恢复的状态切换消耗。线程会进入自旋状态。JDK1.6 引入了自适应自旋。自旋时间根据之前锁自旋时间和线程状态,动态变化,可以能减少自旋的时间。
  5. 轻量级锁(Lightweight Locking):在没有多线程竞争的情况下避免重量级互斥锁,只需要依靠一条 CAS 原子指令就可以完成锁的获取及释放。

在 JDK1.6 之后,synchronized 不再是重量级锁,锁的状态变成以下四种状态:
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

锁的状态

自适应自旋锁

大部分时候,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。这项技术就是所谓的自旋锁。
自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有获取到锁,则该线程应该被挂起。在 JDK1.6 中引入了自适应的自旋锁,自适应意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

所谓自旋,不是获取不到就阻塞,而是在原地等待一会儿,再次尝试(当然次数或者时长有限),他是以牺牲 CPU 为代价来换取内核状态切换带来的开销。借助于适应性自旋,可以在 CPU 时间片的损耗和内核状态的切换开销之间相对的找到一个平衡,进而能够提高性能

偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单地测试一下对象头的 MarkWord 里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下 MarkWord 中偏向锁的标识是否设置成 1(表示当前是偏向锁):如果没有设置,则使用 CAS 竞争锁;如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程,如果失败则进行轻量锁的升级。

轻量级锁

如果说偏向锁是只允许一个线程获得锁,那么轻量级锁就是允许多个线程获得锁,但是只允许他们顺序拿锁,不允许出现竞争,也就是拿锁失败的情况,轻量级锁的步骤如下:

  1. 线程 1 在执行同步代码块之前,JVM 会先在当前线程的栈帧中创建一个空间用来存储锁记录,然后再把对象头中的 MarkWord 复制到该锁记录中,官方称之为 DisplacedMarkWord。然后线程尝试使用 CAS 将对象头中的 MarkWord 替换为指向锁记录的指针。如果成功,则获得锁,进入步骤 3)。如果失败执行步骤 2)
  2. 线程自旋,自旋成功则获得锁,进入步骤 3)。自旋失败,则膨胀成为重量级锁,并把锁标志位变为 10,线程阻塞进入步骤 3)
  3. 锁的持有线程执行同步代码,执行完 CAS 替换 MarkWord 成功释放锁,如果 CAS 成功则流程结束,CAS 失败执行步骤 4)
  4. CAS 执行失败说明期间有线程尝试获得锁并自旋失败,轻量级锁升级为了重量级锁,此时释放锁之后,还要唤醒等待的线程

重量级锁

自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于轻量级锁的状态,如果自旋失败则进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己,需要从用户态切换到内核态实现。(当竞争竞争激烈时,线程直接进入阻塞状态。不过在高版本的 JVM 中不会立刻进入阻塞状态而是会自旋一小会儿看是否能获取锁如果不能则进入阻塞状态。)

总结

可以简单总结是如下场景:

  1. 只有一个线程进入加锁区,锁状态是偏向锁
  2. 多个线程交替进入加锁区,锁状态可能是轻量级锁
  3. 多线程同时进入加锁区,锁状态可能是重量级锁

最后,限于笔者经验水平有限,欢迎读者就文中的观点提出宝贵的建议和意见。如果想获得更多的学习资源或者想和更多的技术爱好者一起交流,可以关注我的公众号『全菜工程师小辉』后台回复关键词领取学习资料、进入后端技术交流群和程序员副业群。同时也可以加入程序员副业群 Q 群:735764906 一起交流。

退出移动版