synchronized最全面的理解

什么是synchronized?

synchronized是java提供的一个关键字。可以用来修饰一个方法,一段代码块,来达到一个锁的作用。

synchronized有什么用,该如何使用?

当被synchronized修饰时,表明这个方法或这段代码同一时刻只能由一个线程执行到,其他想要执行相同方法的线程必须等待,直到之前的线程成功释放锁(执行完后自动释放)之后才可以执行。
使用方法如下:

public class SynDemo {
    public  String str; // synchronized不能修饰类和属性
    /*
        synchronized修饰静态方法
        作用范围:整个类
     */
    public synchronized static void fun1(){
        // TODO
    }

    /*
        synchronized修饰普通方法
        作用范围:一个实例对象
     */
    public synchronized void fun2(){
        // TODO
    }

    /*
         synchronized修饰代码块
         作用范围:指定代码块
     */
    public String fun3(){
        String name = "fun3";
        synchronized (this){
            //todo
        }
        return name;
    }
}

那使用的地方不同,达到的效果有什么不同呢?

  • 1.当修饰静态方法时,synchronized锁定的是整个class对象,即不同线程操作该类的不同实例对象时,只要被synchronized修饰的代码都无法同步访问。
  • 2.当修饰普通方法时,synchronized锁定的是具体的一个实例对象,即该类的不同实例对象之间的锁是隔离的,当多个线程操作的实例对象不一样的,可以同时访问相同的被synchronized修饰的方法。
  • 3.当修饰代码块时,锁的粒度取决于()里面指定的对象,当synchronized(SynDemo.class)时,是和1一样的类锁,当synchronized(this)时,是和2一样的实例对象锁。
  • 4.代码中没有被synchronized修饰的其他方法是不受上诉各种锁的影响的。即一个线程操作a实例对象的同步方法fun1时,此时别的线程是可以同时执行a实例对象的其他非同步方法的。

synchronized的实现原理。

在了解synchronized的实现原理之前,我们需要先对对象的内存布局有个基本了解。对象存储的布局可以分为3块区域,对象头,实例数据和对齐填充。
而HotSpot虚拟机的对象头包括两部分信息:

  • 1.Mark Word 存储对象自身运行时数据,如hashCode,GC分代年龄,锁状态标志,偏向线程id等
  • 2.Class Metadata Address 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例

由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构:

mark word.png

通过上图发现重量级锁的标志为10,并且有个指向重量级锁的指针,指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联。

了解了对象头之后,我们对上诉的代码进行javap -v查看反汇编后的字节码

 public synchronized void fun2();
    descriptor: ()V
    flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 19: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       1     0  this   Lcom/wchao/jbasic/juc/SynDemo;

 public java.lang.String fun3();
    descriptor: ()Ljava/lang/String;
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: ldc           #2                  // String fun3
         2: astore_1
         3: aload_0
         4: dup
         5: astore_2
         6: monitorenter
         7: aload_2
         8: monitorexit
         9: goto          17
        12: astore_3
        13: aload_2
        14: monitorexit
        15: aload_3
        16: athrow
        17: aload_1
        18: areturn

根据上述字节码可以看出当synchronized修饰方法时,jvm是通过添加一个ACC_SYNCHRONIZED访问标志,而修饰代码块时是通过monitorenter和monitorexit指令来实现的。

  • monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor ,重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。
    但是上述fun3的字节码文件中我们发现8行和14行都有一个monitorexit指令,这是因为jvm规定每个monitorenter指令都要求有对应的monitorexit指令配对,为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。所以14行多出的那一个monitorexit指令,就是异常结束时被执行的释放monitor 的指令。
  • 而修饰方法时的ACC_SYNCHRONIZED算是隐式的,它没有通过字节码指令来实现,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor, 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。

上面的实现方法主要是指重量级锁的实现,即监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,效率低下。所以Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了偏向锁,和轻量级锁等。
锁一共有四种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态随着竞争情况逐渐升级。为了提高获得锁和释放锁的效率,锁可以升级但不能降级,意味着偏向锁升级为轻量级锁后不能降级为偏向锁。
我们结合对象头的mark word理解下偏向锁和轻量级锁。

  • 1.偏向锁
    当一个线程访问同步块并获取锁时,会在对象头和栈帧的锁记录里存储偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需测试Mark Word里线程ID是否为当前线程。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要判断偏向锁的标识。如果标识被设置为0(表示当前是无锁状态),则使用CAS竞争锁;如果标识设置成1(表示当前是偏向锁状态),则尝试使用CAS将对象头的偏向锁指向当前线程,触发偏向锁的撤销。偏向锁只有在竞争出现才会释放锁。当其他线程尝试竞争偏向锁时,程序到达全局安全点后(没有正在执行的代码),它会查看Java对象头中记录的线程是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程,撤销偏向锁,升级为轻量级锁,如果线程1不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了

2.轻量级锁

线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的MarkWord复制到锁记录中,即Displaced Mark Word。然后线程会尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁。如果失败,表示其他线程在竞争锁,当前线程使用自旋来获取锁(自旋锁)。当自旋次数达到一定次数时,锁就会升级为重量级锁。轻量级锁解锁时,会使用CAS操作将Displaced Mark Word替换回到对象头,如果成功,表示没有竞争发生。如果失败,表示当前锁存在竞争,锁已经被升级为重量级锁,则会释放锁并唤醒等待的线程。

轻量级锁

你可能感兴趣的:(synchronized最全面的理解)