synchronized同步锁是什么?整理分析下这个一直没去细看,面试时问到又不会回答的问题

作者: admin 分类: JAVA 发布时间: 2020-01-11 13:16  阅读: 86 views

还在刚工作的时候,碰到了简单的并发问题,通过上网搜索,看到了synchronized关键字,往方法上一放,并发问题解决。由于没有很大很复杂的业务量,所以感觉很厉害的样子。

随着工作年限的增加,发现了问题原来不是这么简单,在高并发的场景下,有用synchronzied,有用lock,也有用redis锁处理。还有很多项目是通过业务隔离、分库分表、线程池等设计方式减少并发带来的隐患。

以前的技术视野,就像井底之蛙、一叶障目、以偏概全的感觉。还是要对于一些知识点要恶补一下。

初识synchronized

那还是在用jdk1.5的时候,对于一些代码底层和实现原理根本不关注,只是负责写业务代码。是一个对用户分数变更的方法引起了问题,
方法如下

//根据用户id,变更分数

void updateScore(BigDecimal score,String uid) {
    int i = ScoreMapper.updateScore(score,uid);
    ....
}

有多处业务调用此方法。对于运算结果和演算的结果基本都对应不上。通过打印日志发现,某一次调用时score的变动初始值有问题。

应该是某个时刻,有两个业务同时调用了此方法。所以网上搜索如何保证方法的顺序执行类似,搜到了很多人用synchronized。所以就在嵌套该方法的大方法上添加了这个关键字。通过少量数据测试,是正常的,所以就发布了产品,让客户去用。

synchronized的用法

这个版本的产品发出去后,收到了一些反馈,就是应用程序会经常卡死(3D教学软件),无法正常使用。通过日志查询,首先发现了死锁异常,类似如下(用的是sqlserver2005数据库)

### Error updating database.  Cause: xxx: Deadlock found when trying to get lock; try restarting transaction

通过搜索发现,synchronized有几种用法,可能是用法上出了问题。

1. synchronized修饰静态方法时,锁住的是Class实例
2. synchronized修饰方法时,锁住的是对象的实例(this)
3. 同步块方式,作用于一个对象实例,锁住的是所有以该对象为锁的代码块。

程序里面是应用在了一个大方法上,如下

//改进前 
public synchronized void updateSomeInfo(Object...objects){

    //方法一

    //方法二

    //方法三

    ...

    ...

    updateScore(10,uid);

    ...

    //方法十

}


//改进后 

...
synchronized(this){
    int i = ScoreMapper.updateScore(score,uid);
}
...

应该是锁的颗粒度太大,导致死锁问题。由锁一个大方法,改为锁一段代码,问题解决。

这里说明下,死锁产生的原因及条件

原因:
    竞争资源引起进程死锁

产生条件:必须具备以下四个条件

1. 互斥条件:指进程所分配到的资源进行排他性使用,即一段时间内资源只被一个进程占用。如果其他进程请求资源,只能等待,直到占有进程用毕释放。
2. 请求和保持条件:进程至少已经占有一个资源,又提出新的资源请求,新资源被其他资源占有。此时请求进程阻塞,但又对自己获得的其他资源保持不放。
3. 不剥夺条件:进程已经获得的资源,在未使用完之前,不能被剥夺,只能自己使用完之后释放。
4. 环路等待条件:指发生死锁时,必然存在一个进程-资源的环形链。即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。

synchronized的原理

synchronized 关键字在使用的时候,往往需要指定一个对象与之关联,例如 synchronized(this)。那么有时候在面试的过程中,面试官就会问同步锁的原理是什么?以前不深究代码的时候,只会回答:让多个线程按顺序执行。原理是什么不懂。

既然要了解原理,就要看看加了synchronized关键字前后到底做了什么。这个时候需要去看编译后的源码了。synchronized功能是由c++做的monitor机制和object的线程阻塞/唤起共同完成的。

示例代码如下(两种,同步方法,同步块):

//方法增加同步
package com.chl.lock;
public class singleTest {
    synchronized void test() {
        System.out.println("Hello World!");
    }
}

//增加同步块
package com.chl.lock;
public class singleTest {
    void test() {
        synchronized(this) {
            System.out.println("Hello World!");
        }
    }
}

先使用 javac singleTest.java编译文件

再使用 javap -v -verbose singleTest.class查看编译后源码如下:

//增加同步方法编译后
chenhailongdeMacBook-Pro:lock chenhailong$ javap -v -verbose singleTest.class 
Classfile /Users/chenhailong/eclipse-workspace2/MutilThreadTest/src/com/chl/lock/singleTest.class
  Last modified 2020-1-10; size 414 bytes
  MD5 checksum ee6f44e6f9af42b46e6c7432b89e2a02
  Compiled from "singleTest.java"
public class com.chl.lock.singleTest
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#14         // java/lang/Object."<init>":()V
   #2 = Fieldref           #15.#16        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #17            // Hello World!
   #4 = Methodref          #18.#19        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #20            // com/chl/lock/singleTest
   #6 = Class              #21            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               test
  #12 = Utf8               SourceFile
  #13 = Utf8               singleTest.java
  #14 = NameAndType        #7:#8          // "<init>":()V
  #15 = Class              #22            // java/lang/System
  #16 = NameAndType        #23:#24        // out:Ljava/io/PrintStream;
  #17 = Utf8               Hello World!
  #18 = Class              #25            // java/io/PrintStream
  #19 = NameAndType        #26:#27        // println:(Ljava/lang/String;)V
  #20 = Utf8               com/chl/lock/singleTest
  #21 = Utf8               java/lang/Object
  #22 = Utf8               java/lang/System
  #23 = Utf8               out
  #24 = Utf8               Ljava/io/PrintStream;
  #25 = Utf8               java/io/PrintStream
  #26 = Utf8               println
  #27 = Utf8               (Ljava/lang/String;)V
{
  public com.chl.lock.singleTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 2: 0

  synchronized void test();
    descriptor: ()V
    flags: ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello World!
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 4: 0
        line 5: 8
}
SourceFile: "singleTest.java"

第二种

增加同步块编译后
chenhailongdeMacBook-Pro:lock chenhailong$ javap -v -verbose singleTest.class 
Classfile /Users/chenhailong/eclipse-workspace2/MutilThreadTest/src/com/chl/lock/singleTest.class
  Last modified 2020-1-10; size 526 bytes
  MD5 checksum 623f397b6c3a4733464b4ef22660fba7
  Compiled from "singleTest.java"
public class com.chl.lock.singleTest
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#18         // java/lang/Object."<init>":()V
   #2 = Fieldref           #19.#20        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #21            // Hello World!
   #4 = Methodref          #22.#23        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #24            // com/chl/lock/singleTest
   #6 = Class              #25            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               test
  #12 = Utf8               StackMapTable
  #13 = Class              #24            // com/chl/lock/singleTest
  #14 = Class              #25            // java/lang/Object
  #15 = Class              #26            // java/lang/Throwable
  #16 = Utf8               SourceFile
  #17 = Utf8               singleTest.java
  #18 = NameAndType        #7:#8          // "<init>":()V
  #19 = Class              #27            // java/lang/System
  #20 = NameAndType        #28:#29        // out:Ljava/io/PrintStream;
  #21 = Utf8               Hello World!
  #22 = Class              #30            // java/io/PrintStream
  #23 = NameAndType        #31:#32        // println:(Ljava/lang/String;)V
  #24 = Utf8               com/chl/lock/singleTest
  #25 = Utf8               java/lang/Object
  #26 = Utf8               java/lang/Throwable
  #27 = Utf8               java/lang/System
  #28 = Utf8               out
  #29 = Utf8               Ljava/io/PrintStream;
  #30 = Utf8               java/io/PrintStream
  #31 = Utf8               println
  #32 = Utf8               (Ljava/lang/String;)V
{
  public com.chl.lock.singleTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 2: 0

  void test();
    descriptor: ()V
    flags:
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String Hello World!
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
      LineNumberTable:
        line 4: 0
        line 5: 4
        line 6: 12
        line 7: 22
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 17
          locals = [ class com/chl/lock/singleTest, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4
}
SourceFile: "singleTest.java"

只看最下面的test()相关字节码即可(对于字节码和助记符的解释请查阅相关资料。参考
https://juejin.im/post/589834a20ce4630056097a56)

可以发现增加同步方法和同步块的编译源码不同。但都是通过monitor来实现的

隐式处理:

同步方法,是JVM通过在方法访问标识符(flags)中加入ACC_SYNCHRONIZED来实现同步功能(看第一个编译源码的最后的部分)

显示处理:

同步块,是JVM使用monitorenter和monitorexit两个指令实现同步,直接在代码中插入相关实现。

monitor的运行原理

这是oracle官方文档对monitorenter、monitorexit的解释
https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html#jvms-6.5.monitorenter

直接引用翻译

>> monitorenter

每一个对象都有一个Monitor对象,线程通过执行monitorenter指令尝试获取Monitor对象的拥有权
如果拥有当前Monitor对象的线程数为0,则将_count++,当前线程称为Monitor对象的拥有者。
如果当前线程已经拥有了此Monitor对象,则将_count++即可。
如果其他线程已经拥有了此Monitor对象,则当前线程阻塞直到Monitor的计数_count==0,然后重新竞争获取锁。

>> monitorexit

执行monitorexit指令的线程必须是此Monitor对象的拥有者(否则会抛java.lang.IllegalMonitorStateException异常),线程减少Monitor对象的锁计数.
如果锁计数为0了,则线程不在是Monitor对象的拥有者,其他被这个Monitor对象阻塞的线程可以尝试获取Monitor(之前因没竞争到锁而阻塞的线程需要被执行monitorexit指令的线程唤醒才能重新竞争锁)

盗一张图,对于monitor机制说明的比较清楚(请先理解每一个对象都有一个Monitor对象)

image
对图的解释请看:https://blog.csdn.net/m_xiaoer/article/details/73274642 原作者整理的。

在查找的过程中,提到了Monitor对象,它是由C++写的,关于它的实现原理和源码查看方式,请看
https://www.cnblogs.com/webor2006/p/11442551.html
这就涉及到更底层的逻辑了,不想深入了。(-_- !!)

感觉在技术上刨根问到底的人真是太厉害了。不是泛读,而是认真的分析去探索,牛B。

这里整理实现逻辑也是比较粗浅的。jdk1.6升级后,对Synchronized做了很多优化,后面慢慢整理。

Synchronized和lock接口的区别

在使用jdk1.5的时候(老板不让升级),通过查询知道了还有lock锁。在没有对Synchronized关键字优化时,网上很多人说lock的控制更加灵活且效率更高。后来慢慢开始用它了


// ... Lock lock = new ReentrantLock(); try { lock.lock(); //...... }catch(Exception e) { } lock.unlock(); // ...

从底层实现对比:

synchronized 关键字通过一对字节码指令 monitorenter/monitorexit 实现, 这对指令被 JVM 规范所描述。

java.util.concurrent.Lock 通过 Java 代码搭配sun.misc.Unsafe 中的本地调用实现的

从功能使用对比:

synchronized 使用简单,添加关键字即可。

lock接口,可以显示的控制锁的释放,以及尝试获取锁,从业务实现角度更加灵活,还可以配合Condition类做更多操作。

synchronized新版本的优化

后来在换了公司,开始用上了jdk1.8,有了很多新特性:lamada表达式、元空间、stream等。 而synchronized在jdk1.6后就已经优化过了多次。

优化加了自适应的自旋锁、偏向锁、轻量级锁、重量级锁等。在分析前,少不了一些基本知识的了解。

1.前期了解JAVA对象头

原因:synchronized从偏向锁 -> 轻量级锁 -> 重量级锁的变化中,依赖java对象头中的标志位进行区分。

在java程序中会创建很多对象,运行时为了实现一些额外的功能,需要在对象中添加一些标记字段用于增强对象功能,这些标记字段组成了对象头。分为普通、数据类型,下图为普通类型对象头。

对象头

下面的图片来自参考论文 Eliminating Synchronization-Related Atomic Operations with Biased Locking and Bulk Rebiasing , 可以与上面的表格进行比对参照

对象头

对象头概念整理

Word:内存大小的单位概念,对于32位处理器 1Word=4Bytes,64位处理器1Word=8Bytes

MarkWord:主要用来存储对象自身的运行时数据,如hashcode、gc分代年龄等。markword的位长度为JVM的一个Word大小,也就是说32位JVM的Markword为32位,64位JVM为64位。其中的tag bits标志位可以区分每种锁的状态。

a. 打开偏向锁标记,tagbits为01时,状态为偏向锁
b. 关闭偏向锁标记,tagbits为00时,状态为轻量级锁
c. 关闭偏向锁标记,tagbits为01时,状态为无锁
d. 关闭偏向锁标记,tagbits为10时,状态为重量级锁

在启动java程序是设定jvm参数可以开关偏向锁
开:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关:-XX:+UseBiasedLocking

Thread Id :这里是指持有偏向锁的id

ptr_to_lock_record:指向栈中锁记录的指针。
ptr_to_heavyweight_monitor:指向线程Monitor的指针

关于这部分相关内容是设计在jdk的c++编写的markOop.hpp文件中,地址如下:
http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/oops/markOop.hpp

没有找到比较合适的详细介绍说明地址

2.了解CAS操作

原因:在synchronized升级为重量级锁之前,会触发cas操作

全称为Compare And Swap,是一个CPU层级的原子性操作指令。
属于乐观锁的操作,而synchronized是属于悲观锁。
java.util.concurrent.atomic包下,一系列以Atomic开头的包装类的底层都是以CAS实现的原子性操作。

CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。

CAS操作

CAS缺点:1. 在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。;2. ABA问题;3. 非公平锁(抢到资源就使用,不等待其他更早的阻塞线程)

synchronized缺点:升级为重量级锁后,线程的频繁阻塞/唤醒会存在切换的性能消耗。

synchronized的自旋

自旋锁原理:如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,只需要发生自旋,等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

自旋伪代码如下:

package com.chl.lock;

import java.util.HashMap;

public class Synchronized_zx {

    public static void main(String[] args) {

        HashMap<String,Integer> map = new HashMap<String,Integer>();
        map.put("condition", 1);

        //一个线程在执行业务
        new Thread(new Runnable() {
            @Override
            public void run() {

                try {
                    Thread.sleep( 5 * 1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("第一个线程执行完毕");
                map.put("condition", 5);

            }
        }).start();

        //另一个线程在自旋等待其他线程释放资源
        while(true) {
            if(map.get("condition").toString().equals("5")) {
                System.out.println("自旋线程执行完毕");
                break;
            }else {
                try {
                    System.out.println("自旋等待....");
                    Thread.sleep( 1 * 1000);
                } catch (InterruptedException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            }
        }
    }
}

//执行结果为
自旋等待....
自旋等待....
自旋等待....
自旋等待....
自旋等待....
执行完毕

在atomic系列的原子类中,是通过CAS操作如下:

package java.util.concurrent.atomic;
public final int getAndUpdate(IntUnaryOperator updateFunction) {
    int prev, next;
    do {
        prev = get();
        next = updateFunction.applyAsInt(prev);
    } while (!compareAndSet(prev, next));
    return prev;
}

//通过判断是否为正确的值,进行自旋

但是线程自旋是需要消耗CPU的,说白了就是让CPU在做无用功,线程不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。

jdk1.6及之前是jvm固定设置的,jdk1.7是通过jvm设置的,jdk1.8优化后是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定的(称为自适应自旋)。

如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时竞争线程会停止自旋进入阻塞状态。

在竞争线程由自旋变为阻塞的过程中,锁的状态也会根据情况变化,引出了偏向锁、轻量级锁、重量级锁。

synchronized的偏向锁

成为偏向锁的条件是,上面提到的对象头中的偏向锁标记是打开的。

在可偏向状态下,尝试用CAS操作,将自己的线程ID写入MarkWord。
也就是下图中的 thread ID值会变化。
对象头

如果CAS操作成功,就表示获得了偏向锁,执行同步块代码,在执行后并不会清除对象头中的thread ID。(在同一个线程再次进入同步块的时候,而同步块没有被其他线程访问过的情况下。会直接认为偏向成功,减少了修改对象头的操作)。

如果CAS操作失败,就表示有其他线程抢先获得了偏向锁。该偏向锁就会升级为轻量级锁。

升级过程是通过 MarkWord 中已经存在的 Thread Id找到成功获取了偏向锁的那个线程,然后在该线程的栈帧中补充上轻量级加锁时, 会保存的锁记录(Lock Record),然后将被获取了偏向锁对象的 MarkWord更新为指向这条锁记录的指针。(等待到达全局安全点)

到这里又涉及到了栈区的对象拷贝、安全点等概念,东西太多了。还有偏向锁的撤销和重新偏向等操作。不再展开,点击这里查看相关解释:https://blog.csdn.net/lengxiao1993/article/details/81568130

synchronized的轻量级锁

当超过一个线程访问同步块代码时,就会发生偏向锁 -> 轻量级锁的变化。锁对象可能有两种状态

1.原来已经获取了偏向锁的线程可能已经执行完了同步代码块, 使得对象处于 “闲置状态”,相当于原有的偏向锁已经过期无效了。此时该对象就应该被直接转换为不可偏向的无锁状态。

2.原来已经获取了偏向锁的线程也可能尚未执行完同步代码块, 偏向锁依旧有效, 此时对象就应该被转换为被轻量级加锁的状态。

升级过程:

如果锁对象处于不可偏向的无锁状态,在当前线程的栈桢(Stack Frame)中创建用于存储锁记录(lock record)的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。

如果在此过程中,线程尝试使用 CAS 操作将对象头中的 Mark Word 替换为指向锁记录的指针。
如果成功,当前线程获得锁.如果失败,表示该对象已经被加锁了, 先进行自旋操作, 再次尝试 CAS 争抢, 如果仍未争抢到,则进一步升级锁至重量级锁。

synchronized的重量级锁

升级到重量级锁,就是之前monitor的相关知识了,依赖于操作系统的互斥量(mutex)实现。这种同步方式的成本非常高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。因此,称这种锁为“重量级锁”。

了解到这里,是不是对synchronized的原理有一些新的认识了,当然这里只是比知道synchronized的基本使用,更进了一层,当然还有更深入的知识,就看是否需要去研究了。

那么,在编程的过程中,有其他方式可以用非同步块的方式处理一些问题么。肯定是有的,请看

synchronized与volatile、AQS

volatile

volatile 是一个类型修饰符。拥有以下特性

可见性:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
有序性:禁止进行指令重排序。
原子性: 只能保证对单次读/写的原子性。i++这种操作不能保证原子性。

所以 java.util.concurrent.atomic包下的原子类中的变量值,都被volatile变量所修饰,保证多个线程的可见性。(直接读堆内存)


AQS

AQS:AbstractQuenedSynchronizer抽象的队列式同步器。是除了java自带的synchronized关键字之外的锁机制。
ReentrantLock、ReentrantReadWriteLock底层都是基于AQS来实现的。

这块东西,也可以展开很多细节,自行查阅

总结

通过整理发现,按照以前编写代码的习惯、记录和优化变更时间线进行分析,在不断地加入深入一点的知识和相关性的内容,对于整体的理解还是很有好处的。

技术笔记的整理,常常是个温故而知新的过程,还是需要不断学习,成长的。

参考

https://www.jianshu.com/p/7f8a873d479c (monitor相关)

https://blog.csdn.net/lengxiao1993/article/details/81568130 ( 锁的相关解释)

https://www.jianshu.com/p/3d38cba67f8b (对象头的解释)

http://ifeve.com/ (过程中发现了并发编程这个很牛x的网站)

百度其他零碎知识和本文中的相关链接


   原创文章,转载请标明本文链接: synchronized同步锁是什么?整理分析下这个一直没去细看,面试时问到又不会回答的问题

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!

一条评论

发表评论

电子邮件地址不会被公开。 必填项已用*标注

更多阅读