Synchronized关键字
本参考:圣思园张龙,《并发编程的艺术》等
一 、Synchronized的使用
同步代码块:
Object object = new Object(); synchronized (object) { object.wait(); }
对于上面的Object,可以是除了Lock以外的其他类都可以,只是默认使用Object类
我们看下上面的代码在字节码中的样子:
Code: 0: new #2 // class java/lang/Object 3: dup 4: invokespecial #1 // Method java/lang/Object."<init>":()V 7: astore_1 8: aload_1 9: dup 10: astore_2 11: monitorenter //synchronized开始的时候,获取监视器 12: aload_1 13: invokevirtual #3 // Method java/lang/Object.wait:()V 16: aload_2 17: monitorexit //监视器的退出。 18: goto 26 21: astore_3 22: aload_2 23: monitorexit 24: aload_3 25: athrow 26: return
发现JVM中的同步是基于进入与退出监视器对象(管程对象) (Monitor)来实现的,,管程与信号量参考《操作系统》进程管理部分。每个对象实例都会有一个Monitor对象,Monitor对象会和Java对象一同创建并销 毁。Monitor对象是由C++来实现的。可以理解为进入同步代码块时上锁(monitorenter),正常或者异常离开时执行解锁(monitorexit)。除了自然加锁解锁之外,我们可以手动控制锁,这也是上文wait()方法的作用。在调用wait方法时,线程必须要持有被调用对象的锁,当调用wait方法后,线程就会释放掉该对象的锁(monitor)。在调用Thread类的sleep方法时,线程是不会释放掉对象的锁的。
关于wait与notify和notifyAll方法的总结:
1. 当调用wait时,首先需要确保调用了wait方法的线程已经持有了对象的锁。
2.当调用wait后,该线程就会释放掉这个对象的锁,然后进入到等待状态(wait Set)
3. 当线程调用了wait后进入到等待状态时,它就可以等待其他线程调用相同对象的notify或notifyAll方法来使得自己被唤醒
4. 一旦这个线程被其他线程唤醒后,该线程就会与其他线程一同开始竞争这个对象的锁(公平竞争)﹔只有当该线程获取到了这个对象的锁后,线程才会继续往下执行
5.调用wait方法的代码片段需要放在一个synchronized块或是synchronized方法中,这样才可以确保线程在调用wait方法前已经获取到了对象的锁。
6.当调用对象的notify方法时,它会随机唤醒该对象等待集合(wait Set)中的任意一个线程,当某个线程被唤醒后,它就会与其他线程一同竞争对象的锁。
7.当调用对象的notifyAll方法时,它会唤醒该对象等待集合(wait Set)中的所有线程,这些线程被唤醒后,又会开始竞争对象的锁。
8. 在某—时刻,只有唯—一个线程可以拥有对象的锁。
普通同步方法:
public synchronized void method() { System.out.println("hello world"); }
上面代码的字节码:
flags: (0x0021) ACC_PUBLIC, 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
上面代码的字节码:
对于synchronized关键字修饰方法来说,并没有出现monitorenter与monitorexit指令,而是出现了一个ACC_SYNCHRONIZED标志。JVM使用了ACC_SYNCHRONIZED访问标志来区分一个方法是否为同步方法;当方法被调用时,调用指令会检查该方法是否拥有ACC_SYNCHRONIZED标志,如果有,那么执行线程将会先持有方法所在对象的Monitor对象,然后再去执行方法体;在该方法执行期间,其他任何线程均无法再获取到这个Monitor当线程执行完该方法后,它会释放掉这个Monitor对象。这意味着如果一个类之中有多个普通同步方法,当执行其中一个时,其他会被阻塞,因为这个类继承自Object,锁由Moniter实现,而一个Object只对应一个Moniter。
当多个线程同时访问一段同步代码时,这些线程会被放到一个EntryList集合中,处于阻塞状态的线程都会被放到该列表当中。接下来,当线程获取到对象的Monitor时,Monitor是依赖于底层操作系统的mutex lock来实现互斥的,线程获取mutex成功,则会持有该mutex,这时其他线程就无法再获取到该mutex。如果线程调用了wait方法,那么该线程就会释放掉所持有的mutex,并且该线程会进入到waitset集合(等待集合)中,等待下一次被其他线程调用notify/notifyAll唤醒。如果当前线程顺利执行完毕方法,那么它也会释放掉所持有的mutex。
总结一下:
同步锁在这种实现方式当中,因为Monitor是依赖于底层的操作系统实现,这样就存在用户态与内核态之间的切换,所以会增加性能开销。通过对象互斥锁的概念来保证共享数据操作的完整性。每个对象都对应于一个可称为『互斥锁的标记,这个标记用于保证在任何时刻,只能有一个线程访问该对象。那些处于EntryList与WaitSet中的线程均处于阻塞状态,阻塞操作是由操作系统来完成的,在linux下是通过pthread_mutex_lock函数实现的。线程被阻塞后便会进入到内核调度状态,这会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。
解决上述问题的办法便是自旋。其原理是:当发生对Monitor的争用时,若owner能够在很短的时间内释放掉锁,则那些正在争用的线程就可以稍微等待―下(即所谓的自旋),在owner线程释放锁之后,争用线程可能会立刻获取到锁,从而避免了系统阻塞。不过,当owner运行的时间超过了临界值后,争用线程自旋一段时间后依然无法获取到锁,这时争用线程则会停正自旋而进入到阻塞状态。所以总体的思想是︰先自旋,不成功再进行阻塞,尽量降低阻塞的可能性,这对那些执行时间很短的代码块来说有极天的性能提升。显然,自旋在多处理器(多核心)上才有意义。
静态同步方法:
public static synchronized void method() { System.out.println("hello world"); }
上面代码的字节码如下:
flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED //相比非静态方法多了一个标志 ACC_STATIC Code: stack=2, locals=0, args_size=0 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
可以看到相较于普通同步方法多了ACC_STATIC标记。关于静态同步方法与普通同步方法不冲突,我理解的是普通同步方法是与new出来的对象有关,所以控制该同步的锁由创建出来的对象默认实现,但是重所周知,静态方法的实现不需要new 一个对象也能实现,所以控制静态方法锁的Monitor对象与之前使用同步方法时创建的对象没有关系,所以静态锁与普通锁也没有关系。(其实我好奇的是万物皆对象原则,静态方法的实现,是由什么对象实现的呢,有知道的大哥可以给讲讲,咱不太懂)
八锁问题:分清类锁(静态同步方法)还是对象锁(普通同步方法),非同步,类锁,对象锁互不影响,相同类型锁Monitor一致才阻塞。
二、 jdk1.5后synchronized锁升级
互斥锁的属性:
1,PTHRBAD_MUTEX_TIMED_NP:这是缺省值,也就是普通锁。当一个线程加锁以后,其余请求锁的线程将会形成一个等待队列,
并且在解锁后按照优先级获取到锁。这种策略可以确保资源分配的公平性。
2.PTHREAD_AUTEX_RECURSIVB_NP∶嵌套锁。充许一个线程对同一个锁成功获取多次,并通过unlock解锁。
如果是不同线程请求,则在加锁线程解锁时重新进行竞争。
3,PTHREAD_MOTEX_BRRORCHECK_NP:检错锁。如果一个线程请求同一个锁,则返回EDEADLK,
否则与PTHRBAD_MUTEX_TIMED_NP类型动作相同,这样就保证了当不允许多次加锁时不会出现最简单情况下的死锁。
4。PTHREAD_MUTEX_ADAPTIVE_NP︰适应锁,动作最简单的锁类型,仅仅等待解锁后重新竞争。
在JDK 1.5之前,我们若想实现线程同步,只能通过synchronized关键字这一种方式来达成;底层Java也是通过aynchronized关键字来做到数据的原子性维护的; synchronized关键字是JVM实现的一种内置锁,从底层角度来说,这种锁的获取与释放都是由JVM帮助我们隐式实现的。
从JDK 1.5开始,并发包引入了lock锁,Lock同步锁是基于Java来实现的,因此锁的获取与释放都是通过Java代码来实现与控制的;然而,synchronized是基于底层操作系统的Mutex Lock来实现的,每次对锁的获取与释放动作都会带来用户态与内核态之间的切换,这种切换会极天地增加系统的负担﹔在并发量较高时,也就是说锁的竞争比较激烈时,synchronized锁在性能上的表现就非常差。
从JDK 1.6开始,synchronized锁的实现发生了很大的变化;这种提升涉及到偏向锁、轻量级锁及重量级锁等,从而减少锁的竞争所带来的用户态与内核态之间的切换,这种锁的优化实际上是通过Java对象头中的一些标志伍来去实现的,对于锁的访问与改变,实际上都与Java对象头息息相关。
从JDK 1.6开始,对象实例在堆当中会被划分为三个组成部分︰对象头、实例数据与对齐填充。对象头主要也是由3块内容来构成:1。Mark word2。指向类的指针3。数组长度其中Mark Mord (它记录了对象、锁及垃圾回收相关的信息,在64位的JVM中,其长度也是64bit)的位信息包括了如下组成部分:
1。无锁标记
2。偏向锁标记
3。轻量级锁标记
4。重量级锁标记
对于synchronized锁来说,锁的升级主要都是通过Mark Word中的锁标志位与是否是偏向锁标志位来达成的;synchronized关键字所对应的锁都是先从偏向锁开始,随着锁竞争的不断升级,逐步演化至轻量级锁,最后则变成了重量级锁。对于锁的演化来说,它会经历如下阶段:无锁->偏向锁->轻量级锁->重量级锁

偏向锁:
针对于一个线程来说的,它的主要作用就是优化同一个线程多次获取一个锁的情况;如果一个synchronized方法被一个线程访问,那么这个方法所在的对象就会在其Mark Mord中的将偏向锁进行标记,同时还会有一个字段来存储该线程的ID;当这个线程再次访问同一个synchronized方法时,它会检查这个对象的Mark Word的偏向锁标记以及是否指向了其线程ID,如果是的话,那么该线程就无需再去进入管程(Monitor)了,而是直接进入到该方法体中。

若第一个线程已经获取到了当前对象的锁,这时第二个线程又开始尝试争抢该对象的锁,由于该对象的锁已经被第一个线程获取到,因此它是偏向锁,而第二个线程在争抢时,会爱现该对象头中的Mark Mord已经是偏向锁,但里面存储的线程ID并不是自己(是第一个线程),那么它会进行cas(Compare and Swap) ,从而获取到锁,这里面存在两种情况:
1。获取锁成功∶那么它会直接将Mark Mord中的线程ID由第一个线程变成自己(偏向锁标记位保持不变),这样该对象依然会保持偏向锁的状态。
2。获取锁失败:
第二个线程执行撤销操作,它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈(对应同步方法)会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁(升级为轻量锁),最后唤醒暂停的线程。
轻量级锁:
适合于 —— 无竞争(没有发生同时两个线程都想调用同步方法),不同的线程交替进入同步区域
(1)轻量级锁加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
(2)轻量级锁解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。图2-2是两个线程同时争夺锁,导致锁膨胀的流程图。

锁的优缺点对比:

自旋锁:
若自旋失败(依然无法获取到锁),那么锁就会转化为重量级锁,在这种情况下,无法获取到锁的线程都会进入到Monitor(即内核态)
自旋最大的一个特点就是避免了线程从用户态进入到内核态。
浙公网安备 33010602011771号