对于synchronized大家应该都很熟悉,主要作用是在多线程并发时,保证线程访问共享数据时的线程安全。
它的作用有三点:
它的用法主要是从两个维度上来区分:
这个对象是新建的,跟其他对象无关:
public class SynchronizeDemo implements Runnable { @Override public void run() { test1(); } private void test1(){ System.out.println(Thread.currentThread().getName() + "_: " + new SimpleDateFormat("HH:mm:ss").format(new Date())); synchronized (new SynchronizeDemo()){ try { System.out.println(Thread.currentThread().getName() + "_start_: " + new SimpleDateFormat("HH:mm:ss").format(new Date())); Thread.sleep(2000); System.out.println(Thread.currentThread().getName() + "_end_: " + new SimpleDateFormat("HH:mm:ss").format(new Date())); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) { SynchronizeDemo sd1 = new SynchronizeDemo(); Thread thread1 = new Thread(new SynchronizeDemo(),"thread1"); Thread thread2 = new Thread(new SynchronizeDemo(),"thread2"); Thread thread3 = new Thread(sd1,"thread3"); Thread thread4 = new Thread(sd1,"thread4"); thread1.start(); thread2.start(); thread3.start(); thread4.start(); } } 复制代码
运行结果如图
四个线程同时开始,同时结束, 因为作为锁的对象与线程是属于不同的实例
无所谓哪个类,都会被拦截
private void test2(){ System.out.println(Thread.currentThread().getName() + "_: " + new SimpleDateFormat("HH:mm:ss").format(new Date())); synchronized (SynchronizeDemo.class){ try { System.out.println(Thread.currentThread().getName() + "_start_: " + new SimpleDateFormat("HH:mm:ss").format(new Date())); Thread.sleep(2000); System.out.println(Thread.currentThread().getName() + "_end_: " + new SimpleDateFormat("HH:mm:ss").format(new Date())); } catch (InterruptedException e) { e.printStackTrace(); } } } 复制代码
运行结果如下:
可以看到,类锁一次只能通过一个。
就是把synchronized (SynchronizeDemo.class)改为synchronized (this)
控制台打印结果
可能这显示结果有点歧义,其实多运行几次我们会发现,1和2是同时结束的,3和4永远有先后,因为3,4同属于一个实例
private synchronized void test4(){ ... } 复制代码
打印的结果如下:
thread1_: 22:42:04 thread3_: 22:42:04 thread2_: 22:42:04 thread3_start_: 22:42:04 thread1_start_: 22:42:04 thread2_start_: 22:42:04 thread1_end_: 22:42:06 thread3_end_: 22:42:06 thread2_end_: 22:42:06 thread4_: 22:42:06 thread4_start_: 22:42:06 thread4_end_: 22:42:08 复制代码
在上面方法上加static
thread1_: 22:42:42 thread1_start_: 22:42:42 thread1_end_: 22:42:44 thread4_: 22:42:44 thread4_start_: 22:42:44 thread4_end_: 22:42:46 thread3_: 22:42:46 thread3_start_: 22:42:46 thread3_end_: 22:42:48 thread2_: 22:42:48 thread2_start_: 22:42:48 thread2_end_: 22:42:50 复制代码
实际上,在JVM中,只区分两种不同的用法,修饰代码块与修饰方法,我们可以查看SE8规范, docs.oracle.com/javase/spec…
(英文不好,我有小助手怕不怕)大意是:Java虚拟机中的同步是通过显式(通过使用监视器输入和监视器输出指令)或隐式(通过方法调用和返回指令)的监视器输入和退出来实现的。 显示就是使用monitorenter和monitorexit来控制同步代码块;隐式是修饰方法,在运行时常量池中通过ACC_SYNCHRONIZED来标志。
多说无益,直接看它的字节码
public class Test { public static void main(String[] args) { } public synchronized void test1() { } public void test2() { synchronized (this) { } } } 复制代码
最简单的程序,通过使用 javap -v Test.class
来查看它的字节码(注意是class文件,不是java文件)
public synchronized void test1(); descriptor: ()V flags: ACC_PUBLIC, ACC_SYNCHRONIZED Code: stack=0, locals=1, args_size=1 0: return LineNumberTable: line 8: 0 LocalVariableTable: Start Length Slot Name Signature 0 1 0 this LTest; public void test2(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=3, args_size=1 0: aload_0 1: dup 2: astore_1 3: monitorenter //监视器进入,获取锁 4: aload_1 5: monitorexit //监视器退出,释放锁 6: goto 14 9: astore_2 10: aload_1 11: monitorexit 12: aload_2 13: athrow 14: return 复制代码
可以看到,果然字节码中,synchronized修饰代码块时,是使用 monitorenter
和 monitorexit
来控制,而synchronized修饰方法的时候,是使用 ACC_SYNCHRONIZED
标识。
本质上都是对一个对象的monitor进行获取,而这个获取的过程是排他的,也就是同一时刻只能有一个线程获得同步块对象的监视器monitor。
线程执行到monitorenter指令时,会尝试获取对象所对应的monitor所有权,也就是尝试获取锁,执行到monitorexit,也就是释放所有权,释放锁。
要想理清synchronized的锁的原理,需要掌握两个重要的概念:
在Hotspot虚拟机中,对象在内存中的存储布局,可以分为三块:对象头Header,实例数据Instance Data,对齐填充Padding。
Hotspot虚拟机的对象头包含了两部分信息:
32位HotSpot虚拟机的对象头存储结构如下
为了验证上图的正确,我们可以查看hotspot的源码
在线地址 : hg.openjdk.java.net/jdk8u/jdk8u…
public: // Constants enum { age_bits = 4,//分代年龄 lock_bits = 2,//锁标识 biased_lock_bits = 1,//是否偏向锁 max_hash_bits = BitsPerWord - age_bits - lock_bits - biased_lock_bits, hash_bits = max_hash_bits > 31 ? 31 : max_hash_bits,//hask cms_bits = LP64_ONLY(1) NOT_LP64(0), epoch_bits = 2//偏向时间戳 }; 复制代码
hash:保存对象的哈希码
age:对象的分代年龄
biased_lock:偏向锁标识位
lock:锁状态标识位
JavaThread*:保存持有偏向锁的线程ID
epoch:保存偏向时间戳
所以,对象头中的Mark Word, synchronized源码就是用了对象头中的Mark Word来标识对象加锁状态。
Monitor Record是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor record关联(对象头的MarkWord中的LockWord指向monitor record的起始地址),同时monitor record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。如下图所示为Monitor Record的内部结构
线程唯一标识,当锁被释放时又设置为NULL; EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。 RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。 Nest:用来实现重入锁的计数。 HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争
简单总结一下,同步块使用monitorenter和monitorexit指令,而同步方法是依靠方法修饰符上的flag——ACC_SYNCHRONIZED来完成的。其本质都是对一个对象监视器monitor进行获取,这个获取过程是排他的,也就是同一时刻只能有一个线程获得由synchronized所保护的对象的监视器。而这个监视器,也可以理解为一个同步工具,它是由java对象进行描述的,在Hotspor中,是通过ObjectMonitor来实现,每个对象中天然都内置了一个ObjectMonitor对象。
在java中,synchronized在编译后,会在同步块的前后分别形成一个monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象,如果java程序中明确指定了对象,那就是这个对象的reference,如果没有指明,那么根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或者类Class对象来做锁对象。
在执行monitorenter时,首先会尝试获取对象的锁,如果这个对象没有锁,或者当前线程已经拥有了这个对象的锁,那个锁的计数器加1,相应的,在执行monitorexit时指令时,会将锁计数器减1,当计数器为0时,这个锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
synchronized同步块对同一线程来说是可重入的,不会出现自己把自己锁死的情况,其次,同步块在已进入的线程执行完成前,会阻塞后面的其他线程进入。我们知道,Java的线程是映射到操作系统中的的原生线程上的,如果要阻塞或者唤醒一个线程,都需要操作系统来帮忙,这就需要我们从用户态切换到核心态,因此这个状态转换是非常耗费CPU。如果这个代码非常简单的同步块,可能切换状态的时间比代码执行时间还长。所以synchronized是一个重量级的操作,虚拟机本身也做了大量的优化,引入了偏向锁,轻量级锁,重量级锁等,这一部分锁的升级,可以等以后有时间了,再慢慢探讨。当然还可以引入重入锁,解决synchronized过于重量的问题。
参考
jdk源码剖析三:锁Synchronized
Java中synchronized的实现原理与应用
《深入理解Java虚拟机》
我的CSDN
下面是我的公众号,欢迎大家关注我