Java并发编程(五):synchronized原理详解及锁优化

1 引言

线程运行时拥有自己私有的栈空间,会在自己的栈空间运行,如果多线程间没有共享的数据也就是说多线程间并没有协作完成一件事情,那么,多线程就不能发挥优势,不能带来巨大的价值。那么共享数据的线程安全问题怎样处理?很自然而然的想法就是每一个线程依次去读写这个共享变量,这样就不会有任何数据安全的问题,因为每个线程所操作的都是当前最新的版本数据。那么,在Java关键字synchronized就具有使每个线程依次排队操作共享变量的功能。很显然,这种同步机制效率很低,但synchronized是其他并发容器实现的基础,对它的理解大有裨益。

2 synchronized实现原理

在Java代码中,synchronized可使用在代码块和方法中,synchronized的使用场景如下:
在这里插入图片描述
如图,synchronized可以用在方法上也可以使用在代码块中,其中方法是实例方法和静态方法分别锁的是该类的实例对象和该类的对象。而使用在代码块中也可以分为三种,具体的可以参看上面的表格。这里的需要注意的是:如果锁的是类对象的话,尽管new多个实例对象,但他们仍然是属于同一个类,依然会被锁住,即线程之间保证同步关系

现在我们已经初步了解了synchronized,大家想不想进一步知道synchronized底层是怎样实现的呢?少侠就和大家一起学习synchronized的底层实现。

2.1 监视器锁(monitor)机制
2.1.1 作用在方法上

话不多说,先上demo:

public class ThreadService {

    public static void main(String[] args) {
        lockDemo1();
    }

    public static synchronized void lockDemo1() {
        System.out.println("Lock Class");
    }
}

编译之后进入ThreadService.class的文件夹下执行命令:javap -v ThreadService.class,查看反编译结果:

 public static synchronized void lockDemo1();
    descriptor: ()V
    flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #4                  // String Lock Class
         5: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 24: 0
        line 25: 8

重点关注上面反编译结果中的flags字段,有个值是ACC_SYNCHRONIZED当其他线程调用该方法时看到有这个标志位时就不能得到该方法的执行权,会阻塞等待

2.1.2 作用在代码块

同样的先上demo,

public class ThreadService {
    public static void main(String[] args) {
        synchronized (ThreadService.class) {
            System.out.println("Lock Class");
        }
        show();
    }
    
    private static void show() {
        System.out.println("show synchronized");
    }
}

编译后进入ThreadService.class的文件夹下执行命令:javap -v ThreadService.class,查看反编译执行结果:

 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc_w         #30                 // class com/jd/jr/lottery/service/cps/ThreadService
         3: dup
         4: astore_1
         5: monitorenter             //注意此处,进入同步代码块
         6: getstatic     #31                 // Field java/lang/System.out:Ljava/io/PrintStream;
         9: ldc           #32                 // String Lock Class
        11: invokevirtual #33                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        14: aload_1
        15: monitorexit             //注意此处,退出同步代码块
        16: goto          24
        19: astore_2
        20: aload_1
        21: monitorexit            //注意此处,退出同步代码块
        22: aload_2
        23: athrow
        24: invokestatic  #34                 // Method show:()V
        27: return

synchronized 应用在同步块上时,在字节码中是通过 monitorenter 和 monitorexit 实现的。
如上述代码,当主线程进入同步代码块时,获取监视器锁,计数器自增成1,当执行show()方法时,由于锁定的是类对象,show()是static的,也属于类,所以show()方法也是同步互斥的,执行show()方法时计数器变为2,当方法执行结束,需要两次退出同步代码块(如上图的15行和21行)。
每个对象都会与一个monitor相关联,当某个monitor被拥有之后就会被锁住,当线程执行到monitorenter指令时,就会去尝试获得对应的monitor

  1. 每个monitor维护着一个私有的计数器。未被拥有的monitor的该计数器为0,当一个线程获得monitor(执行monitorenter)后,该计数器自增变为 1 。
  • 当同一个线程再次获得该monitor的时候,计数器再次自增(可重入性);
  • 当不同线程想要获得该monitor的时候,就会被阻塞,获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor
  1. 当同一个线程释放 monitor(执行monitorexit指令)的时候,计数器再自减。

锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。synchronized先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一

2.2 获取锁和释放锁的内存语义

先看以下的示例代码,SynchronizedDemo类有两个同步方法,writeread,由上述的分析可知这两个方法锁住的都是类的实例对象

/**
 * @author Carson
 * @date 2020/5/16 19:20
 */
public class Main {
    private static ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("Main -%d").build();

    private static ExecutorService threadPool = new ThreadPoolExecutor(5, 200, 10000L, TimeUnit.MILLISECONDS,
        new LinkedBlockingQueue<Runnable>(1024), namedThreadFactory, new ThreadPoolExecutor.DiscardOldestPolicy());
        
    public static void main(String[] args) {
        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
        threadPool.submit(() ->
                synchronizedDemo.write()
        );
        Future<Integer> future = threadPool.submit(() ->
                synchronizedDemo.read());
        Integer integer = future.get();
        System.out.println(integer);
    }
}

class SynchronizedDemo {
    private int num = 0;

    public synchronized void write() {
        num++;
    }

    public synchronized int read() {
        return num;
    }
}

我们在主线程main方法里分别依次启动两个子线程T1和T2,分别执行write和read方法,根据结果可知:T2输出的结果为1。这表明T1对参数的修改对T2线程是可见的,这就证明了synchronized是可以保证内存可见性的。注意下图红线,这里包含一个happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before对该监视器的加锁。也就是说,只有当线程T1释放锁之后线程T2才有机会获取监视器锁。
在这里插入图片描述
线程T1的执行结果(num=1)对线程T2可见,实现原理为:释放锁的时候将工作内存的更新值刷新到主内存中,然后其它线程会强制从主内存中获取最新的值。其实也就是线程T1和线程T2通过主内存中的共享变量num进行通信。此外,线程T1进入临界区happens-before线程T2进入临界区,根据happens-before原则,线程T1的执行结果对线程T2可见。

3 synchronized的锁优化

我们已经知道,synchronized是重量级锁(优化前),它最大的特征就是在同一时刻只有一个线程能够获得对象的监视器(monitor),从而进入到同步代码块或者同步方法之中,即表现为互斥性(排它性),存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,这种获取锁的方式效率十分低下,每次只能通过一个线程,所以我们可以通过缩短获取锁的时间来进行优化。

3.1 JVM中对象的内存区域

JVM 中,对象在内存中分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头:我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

  • Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化,Mark Word组成如下所示:
    在这里插入图片描述
  • Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。对象的MarkWord变化为下图:
在这里插入图片描述

3.2 偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

3.2.1 偏向锁的获取

当一个线程访问同步块并获取锁时,会在对象头栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作(Compare and swap)来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

3.2.2 偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

3.3 轻量级锁
3.3.1 加锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

3.3.2 解锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

3.4 各种锁的比较
优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块的场景
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到锁竞争的线程,使用自旋会消耗CPU追求响应时间或同步块执行速度非常快
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢不追求响应时间或同步代码块执行速度较快
3.5 小结

这里以一张图小结,有点复杂,但是经过前面的铺垫,相信读者可以理通。如下:
在这里插入图片描述
synchronized是JVM层面的同步机制,在面试中经常出现,熟练掌握并知悉底层实现原理,相信在面试中能给面试官留下一个不错的印象。

点点关注,不会迷路

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值