欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Java并发编程:Synchronized及其实现原理

程序员文章站 2024-02-06 23:21:16
...

  Synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。Synchronized的作用主要有三个:(1)确保线程互斥的访问同步代码(2)保证共享变量的修改能够及时可见(3)有效解决重排序问题。从语法上讲,Synchronized总共有三种用法:

  • 同步普通方法,锁的是当前对象。
  • 同步静态方法,锁的是当前 Class 对象。
  • 同步块,锁的是 () 中的对象。

实现原理:

JVM 是通过进入、退出对象监视器( Monitor )来实现对方法、同步块的同步的。

具体实现是在编译之后在同步方法调用前加入一个 monitor.enter 指令,在退出方法和异常处插入 monitor.exit 的指令。

其本质就是对一个对象监视器( Monitor )进行获取,而这个获取过程具有排他性从而达到了同一时刻只能一个线程访问的目的。

而对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程 monitor.exit 之后才能尝试继续获取锁。

流程图如下:

Java并发编程:Synchronized及其实现原理

通过一段代码来演示:

public static void main(String[] args) {
    synchronized (Synchronize.class){
        System.out.println("Synchronize");
    }
}

使用 javap -c Synchronize 可以查看编译之后的具体信息。

public class com.crossoverjie.synchronize.Synchronize {
  public com.crossoverjie.synchronize.Synchronize();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return
  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // class com/crossoverjie/synchronize/Synchronize
       2: dup
       3: astore_1
       **4: monitorenter**
       5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       8: ldc           #4                  // String Synchronize
      10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      13: aload_1
      **14: monitorexit**
      15: goto          23
      18: astore_2
      19: aload_1
      20: monitorexit
      21: aload_2
      22: athrow
      23: return
    Exception table:
       from    to  target type
           5    15    18   any
          18    21    18   any
}

可以看到在同步块的入口和出口分别有 monitorenter,monitorexit 指令。

synchronized的特点:Java并发编程:Synchronized及其实现原理

锁优化

synchronized 很多都称之为重量锁,JDK1.6 中对 synchronized 进行了各种优化,为了能减少获取和释放锁带来的消耗引入了偏向锁轻量锁

偏向锁

引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。

偏向锁的特征是:锁不存在多线程竞争,并且应由一个线程多次获得锁。

偏向锁的获取过程

  1. 测试对象头Mark Word(默认存储对象的HashCode,分代年龄,锁标记位)里是否存储着指向当前线程的偏向锁。
  2. 若测试失败,则测试Mark Word中偏向锁标识是否设置成1(表示当前为偏向锁)
  3. 没有设置则使用CAS竞争,否则尝试使用CAS将对象头的偏向锁指向当前线程

偏向锁的释放

  1. 等待全局安全点(在这个时间点上没有正在执行的字节码),暂停拥有偏向锁的线程,检查线程是否存活
  2. 处于非活动状态,则设置为无锁状态
  3. 存活,则重新偏向于其他线程或者恢复到无锁状态或者标记对象不适合作为偏向锁
  4. 唤醒线程

Java并发编程:Synchronized及其实现原理

轻量锁

轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。

轻量级锁的加锁过程

线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。

  • 如果成功使用CAS将对象头重的Mark Word替换为指向锁记录的指针,则获得锁,失败则当前线程尝试使用自旋(循环等待)来获取锁。

轻量级锁的解锁过程:

  • 通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
  • 如果替换成功,整个同步过程就完成了。
  • 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。

Java并发编程:Synchronized及其实现原理

重量级锁

其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程,进行竞争。

三种锁的对比

Java并发编程:Synchronized及其实现原理

通俗来讲就是:

 重量级锁、轻量级锁和偏向锁之间转换

  • 偏向锁:仅有一个线程进入临界区
  • 轻量级锁:多个线程交替进入临界区
  • 重量级锁:多个线程同时进入临界区

Java并发编程:Synchronized及其实现原理

简化版

Java并发编程:Synchronized及其实现原理

其他优化 

1、适应性自旋(Adaptive Spinning):从轻量级锁获取的流程中我们知道当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

2、锁粗化(Lock Coarsening):锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。举个例子:

package com.paddx.test.string;

public class StringBufferTest {
    StringBuffer stringBuffer = new StringBuffer();

    public void append(){
        stringBuffer.append("a");
        stringBuffer.append("b");
        stringBuffer.append("c");
    }
}

这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。

3、锁消除(Lock Elimination):锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。看下面这段程序:

package com.paddx.test.concurrent;

public class SynchronizedTest02 {

    public static void main(String[] args) {
        SynchronizedTest02 test02 = new SynchronizedTest02();
        //启动预热
        for (int i = 0; i < 10000; i++) {
            i++;
        }
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            test02.append("abc", "def");
        }
        System.out.println("Time=" + (System.currentTimeMillis() - start));
    }

    public void append(String str1, String str2) {
        StringBuffer sb = new StringBuffer();
        sb.append(str1).append(str2);
    }
}

虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中逃逸出去,所以其实这过程是线程安全的,可以将锁消除。下面是我本地执行的结果:

Java并发编程:Synchronized及其实现原理

为了尽量减少其他因素的影响,这里禁用了偏向锁(-XX:-UseBiasedLocking)。通过上面程序,可以看出消除锁以后性能还是有比较大提升的。

注:可能JDK各个版本之间执行的结果不尽相同,这里采用的JDK版本为1.6。

扩展

其他控制并发/线程同步方式还有 Lock/ReentrantLock。

Synchronized 和 ReenTrantLock 的对比

① 两者都是可重入锁

两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

② synchronized依赖于JVM,而ReenTrantLock依赖于API

synchronized是依赖于JVM实现的,前面我们也讲到了 虚拟机团队在JDK1.6为synchronized关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReenTrantLock是JDK层面实现的(也就是API层面,需要lock()和unlock()方法配合try/finally语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

③ ReenTrantLock比synchronized增加了一些高级功能

相比synchronized,ReenTrantLock增加了一些高级功能。主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)

  • ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
  • ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReenTrantLock默认情况是非公平的,可以通过ReenTrantLoc类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。
  • synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制,ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。Condition是JDK1.5之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify/notifyAll()方法进行通知时,被通知的线程是由JVM选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是Condition接口默认提供的。而synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法只会唤醒注册在该Condition实例中的所有等待线程。

如果你想使用上述功能,那么选择ReenTrantLock是一个不错的选择。

④ 性能已不是选择标准

在JDK1.6之前,synchronized的性能是比ReenTrantLock差很多。具体表示为:synchronized关键字吞吐量随线程数的增加,下降得非常严重。而ReenTrantLock 基本保持一个比较稳定的水平。在JDK1.6之后JVM团队对synchronized关键字做了很多优化,性能基本能与ReenTrantLock持平。所以JDK1.6之后,性能已经不是选择 synchronized 和ReenTrantLock的影响因素,而且虚拟机在未来的性能改进中会更偏向于原生的synchronized,所以还是提倡在synchronized能满足你的需求的情况下,优先考虑使用synchronized关键字来进行同步!优化后的synchronized和ReenTrantLock一样,在很多地方都是用到了CAS操作。

CAS的原理是通过不断的比较内存中的值与旧值是否相同,如果相同则将内存中的值修改为新值,相比于synchronized省去了挂起线程、恢复线程的开销。

// CAS的操作参数
// 内存位置(A)
// 预期原值(B)
// 预期新值(C)

// 使用CAS解决并发的原理:
// 1. 首先比较A、B,若相等,则更新A中的值为C、返回True;若不相等,则返回false;
// 2. 通过死循环,以不断尝试尝试更新的方式实现并发

// 伪代码如下
public boolean compareAndSwap(long memoryA, int oldB, int newC){
    if(memoryA.get() == oldB){
        memoryA.set(newC);
        return true;
    }
    return false;
}

具体使用当中CAS有个先检查后执行的操作,而这种操作在 Java 中是典型的不安全的操作,所以CAS在实际中是由C++通过调用CPU指令实现的。
具体过程:

  1. CAS在Java中的体现为Unsafe类。
  2. Unsafe类会通过C++直接获取到属性的内存地址。
  3. 接下来CAS由C++的Atomic::cmpxchg系列方法实现。

AtomicInteger的 i++ 与 i-- 是典型的CAS应用,通过compareAndSet & 一个死循环实现。

private volatile int value; 
/** 
* Gets the current value. 
* 
* @return the current value 
*/ 
public final int get() { 
   return value; 
} 
/** 
* Atomically increments by one the current value. 
* 
* @return the previous value 
*/ 
public final int getAndIncrement() { 
   for (;;) { 
       int current = get(); 
       int next = current + 1; 
       if (compareAndSet(current, next)) 
           return current; 
   } 
} 

/** 
* Atomically decrements by one the current value. 
* 
* @return the previous value 
*/ 
public final int getAndDecrement() { 
   for (;;) { 
       int current = get(); 
       int next = current - 1; 
       if (compareAndSet(current, next)) 
           return current; 
   } 
}

Synchronized 与 ThreadLocal 的对比

Synchronized 与 ThreadLocal(有关ThreadLocal的知识会在之后的博客中介绍)的比较:

  1. Synchronized关键字主要解决多线程共享数据同步问题;ThreadLocal主要解决多线程中数据因并发产生不一致问题。
  2. Synchronized是利用锁的机制,使变量或代码块只能被一个线程访问。而ThreadLocal为每一个线程都提供变量的副本,使得每个线程访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。