Synchronized关键字原理

一、并发编程中常见的三个问题

可见性(Visibility):是指一个线程对共享变量进行修改,另一个线程立即得到修改后的最新值。

原子性(Atomicity):在一次或多次操作中,要么所有的操作都执行并且不会受其他因素干扰而中断,要么所有的操作都不执行。

有序性(Ordering):是指程序中代码的执行顺序,Java在编译时和运行时会对代码进行优化,会导致程序最终的执行顺序不一定就是我们编写代码时的顺序。

Java并发压测工具jcstress

二、JVM(Java内存模型)

        Java内存模型,是Java虚拟机规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别。

        Java内存模型是一套规范,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。

        主内存是所有线程都共享的,都能访问的。所有的共享变量都存储于主内存。

        每一个线程有自己的工作内存,工作内存只存储该线程对共享变量的副本。线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量。

         Java内存模型是一套在多线程读写共享数据时,对共享数据的可见性、有序性、和原子性的规则和保障。

CPU缓存,内存与Java内存模型的关系

 主内存与工作内存之间的交互

        当线程操作共享变量的时候,先不看lock和unlock,假设线程1想要访问共享变量x,会先read读取到共享变量,然后通过load,把共享变量加载到工作内存中,接着如果线程要对这个变量进行use操作,比如取到x的值,对这个值进行操作,把操作之后会得到一个新的值,通过assign再赋值给x,操作完成后要同步到主内存,做一个store操作,表示要来保存这个值,在做一个write操作,把最新的值同步到主内存中,而lock和unlock和锁有关,比如加了synchronized,才有这两个操作。

注意:

1.如果对一个变量执行lock操作,将会清空工作内存中此变量的值

2.对一个变量执行unlock操作之前,必须先把此变量同步到主内存中

总结:

        Java内存模型中定义了以下8种操作来完成,主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。

主内存与工作内存之间的数据交互过程:

lock -> read -> load -> use -> assign -> store -> write -> unlock

三、Synchronized保证三大特性

1.Synchronized保证原子性的原理

synchronized保证只有一个线程拿到锁,能够进入同步代码块。不会受到其他线程的干扰。

2.synchronized保证可见性的原理

执行synchronized时,对应lock原子操作,会刷新工作内存共享变量的值。

3.synchronized与有序性

为什么要进行重排序?为了提高程序的执行效率,编译器和CPU会对程序中代码进行重排序。

as-if-serial语义:不管编译器和CPU如何重新排序,必须保证在单线程下程序的结果是正确的。

不可重排序案例

//写后读
int a = 1; 
int b = a;
//写后写
int a = 1; 
int a = 2;
//读后写
int a = 1; 
int b = a; 
int a = 2;

可以重排序案例

int a = 1; 
int b = a; 
int a = 2;
也可以重排序这样:
int b = 2; 
int a = 1; 
int c = a + b;

a和c,b和c之间存在数据依赖关系,不能重排序;a和b之间没有数据依赖关系,可以重排序。

Synchronized保证原子性的原理:加了Synchronized后,依然会发生重排序,只不过,我们有同步代码块,可以保证只有一个线程执行同步代码块中的代码,保证有序性。

四、Synchronized的特性

1.可重入特性

什么是可重入

一个线程可以多次执行synchronized,重复获取同一把锁。

可重入原理

synchronized的锁对象中有一个计数器(recursions变量)会记录线程获得几次锁.

        获取一次锁,计数器加一,一个同步代码块结束,计数器减一,当cpu切换到另一个线程操作的时候,发现没有锁,会线程等待,这时CPU切换到当前线程,继续执行到一个同步代码块结束,计数器减一,直到减到零,表示没有任何线程来获取这个锁了,把这个锁还回去,等待其他线程可以竞争获取这个锁。

可重入的好处

1.可以避免死锁

2.可以让我们更好的封装代码(用方法封装)

小结

        synchronized是可重入锁,内部锁对象中会有一个计数器记录线程获取几次锁了,在执行完同步代码块时,计数器的数量会减一,直到计数器的数量为零,就释放这个锁。

2.不可中断特性

什么是不可中断

        一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断。

小结

        不可中断是指当一个线程获得锁后,另一个线程一直处于阻塞或线程等待状态,前一个线程不释放锁,后一个线程会一直阻塞或等待,不可被中断。

synchronized属于不可被中断的。

lock的lock方法时不可中断的。

lock的try lock方法是可中断的。

五、SYnchronized原理

monitorenter

每一个对象都会和一个监视器monitor关联。监视器被占用时会被锁住,其他线程无法来获取该monitor。
当JVM执行某个线程的某个方法内部的monitorenter时,它会尝试去获取当前对象对应的monitor的所有权。其过程如下:
1.若monior的进入数为0,线程可以进入monitor,并将monitor的进入数置为1。当前线程成为monitor的owner(所有者)
2.若线程已拥有monitor的所有权,允许它重入monitor,则进入monitor的进入数加1
3.若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程会被阻塞,直到monitor的进入数变为0,才能重新尝试获取monitor的所有权。
 

monitorenter小结

        synchronized的锁对象会关联一个monitor,这个monitor不是我们主动i创建的,是JVM的线程执行到这个同步代码块,发现锁对象没有monitor,就会创建monitor,monitor内部有两个重要的成员变量owner:拥有这把锁的线程;recursions:会记录线程拥有所得次数,当一个线程拥有monitor后,其他线程只能等待。

monitorexit

1.能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程。
2.执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。

monitor释放锁

monitor插入在方法结束处和异常处,JVM保证每个monitor必须有对应的monitorexit。

面试题:Synchronized出现异常会释放锁吗?

会释放锁。

同步方法

        可以看到同步方法在反汇编后,会增加ACC_SYNCHRONIZED修饰,会隐式的调用monitorenter和monitorexit,在执行同步方法前会调用monitorenter,在执行完同步方法后会调用monitorexit。

小结

        通过Java反汇编我们看到synchronized使用编程了monitorenter和monitorexit两个指令,每个锁对象都会关联一个monitor(监视器,它才是真正的锁对象),它内部有两个 重要的成员变量owner会保存获得锁的线程,recursions会保存线程获得锁的次数,当执行到monitorexit时,recursions会减一,当计数器减到0时,这个线程就会释放锁。

面试题:synchronized和Lock的区别

1.synchronized是Java的一个关键字,而Lock是一个接口;

2.synchronized在发生异常的时候会自定释放锁,因此不会出现死锁,而Lock在发生异常的时候必须手动释放锁(unlock),可能引起死锁的发生(所以最好将同步代码块用try catch包起来,finally中写入unlock,避免死锁的发生);

3.synchronized只能等待锁的释放,不能响应中断,Lock等待锁的过程可以被interrupt中断也可以不被中断;

4.通过Lock的trylock方法可以知道线程有没有拿到锁,而synchronized不能;

5.synchronized能锁住方法和代码块,而Lock只能锁住代码块;

6.Lock可以提高多线程进行读操作的效率(可以通过Reentrantreadwritelock实现读写分离);

7.synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。

深入JVM源码monitor监视器锁

此处省略了锁的自旋优化等操作,同一在后面的synchronized优化中讨论 

monitor竞争

1.通过CAS尝试把monitor的owner字段设置为当前线程

2.如果设置之前的owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行recursions++,记录重入的次数;

3.如果当前线程是第一次进入该monitor,设置recursions为1,_owner为当前线程,该线程成功获得锁并返回;

4.如果获取锁失败,则等待锁的释放。

monitor等待

竞争失败等待调用的是ObjectMonitor对象的Enterl方法

1.当前线程被封装成ObjectMonitor对象的node,状态设置成ObjectWaiter::TS_CXQ.

2.在for循环中,通过CAS把node节点push到_cxq列表中,同一时刻可能有多个线程把自己的node节点push到_cxq列表中。

3.node节点push到_cxq列表之后,通过自旋尝试获取锁,如果还是没有获取到锁,则通过park将当前线程挂起,等待被唤醒;

4.当该线程被唤醒时,会从挂起的节点继续执行,通过ObjectMonitor::TryLock尝试获取锁。

monitor释放

        当某个持有锁的线程执行完同步代码块时,会进行锁的释放,给其他的线程机会执行同步代码,在HotSpot中,通过退出monitor的方式实现锁的释放,并通知被阻塞的线程,具体实现位于ObjectMonitor的exit方法中。

1.退出同步代码块会让_recursions减一,当_recursions的值减为0时,说明线程释放了锁。

2.根据不同的策略(由QMode指定),从cxq或EntryList中获取头节点,通过ObjectMonitor::ExitEpilog方法唤醒该节点封装的线程,唤醒操作最终由unpark完成。

六、JDK1.6 synchronized优化

monitor重量级锁

        可以看到ObjectMonitor的函数调用中会涉及到Atomic::cmpxchg_ptr,Atomic::inc_ptr等内核函数执行同步代码块,没有竞争到锁的对象会park()被挂起,竞争到锁的线程会unpark()唤醒。这个时候就   会存在操作系统用户态和内核态的转换,这种切换会消耗大量的系统资源。所以synchronized是Java语言中是一个重量级(Heavyweight)的操作。

​​​​​​​

 

CAS概念

        CAS的全称是: Compare And  Swap(比较相同再交换)。是现代CPU广泛支持的一种对内存中的共享数据进行操作的一种特殊指令

        CAS的作用:CAS可以将比较和交换转换为原子操作,这个原子操作直接由CPU保证。CAS可以保证共享变量赋值时的原子操作。CAS操作依赖3个值:内存中的值V,旧的预估值X,要修改的新值B,如果旧的预估值X等于内存中的值V,就将新的值B保存到内存中。

 

         悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这  样别人想拿这个数据就会阻塞。因此synchronized我们也将其称之为悲观锁。JDK中的ReentrantLock   也是一种悲观锁。性能较差!

        乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,就算改了也没关系,再重试即可。所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去修改这个数据,如何没有人修改则更新,如果有人修改则重试。

        CAS这种机制我们也可以将其称之为乐观锁。综合性能较好

小结

CAS获取共享变量时,为了保证该变量的可见性,需要使用volatile修饰。结合CAS和volatile可以  实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下。

1.因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一。

2.但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响。

Synchronized锁升级的过程

对象头的布局

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如图所示

对象头

对象头由两部分组成,一部分用于存储自身的运行时数据,称之为 Mark Word,另外一部分是类型指针,及对象指向它的类元数据的指针。

当一个线程尝试访问synchronized修饰的代码块时,它首先要获得锁,那么这个锁到底存在哪里呢?是存在锁对象的对象头中的。

        Mark  Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。

32位

 64位

        klass pointer这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。为了节约内存可以使用选项-XX:+UseCompressedOops 开启指针压缩。

实例数据

        就是类中定义的成员变量

对齐填充

        仅仅起着占位符的作用,由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的  整数倍。而对象头正好是8字节的倍数,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

偏向锁

什么是偏向锁?

        偏向锁是JDK  6中的重要引进,因为HotSpot作者经过研究实践发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。

        偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,会在对  象头存储锁偏向的线程ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及ThreadID即可。

偏向锁的原理

当线程第一次访问同步块并获取锁时,偏向锁处理流程如下:

1.虚拟机将会把对象头中的标志位设为“01”,即偏向模式。

2.同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中 ,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何 同步操作,偏向锁的效率高。

偏向锁的撤销

1.偏向锁的撤销动作必须等待全局安全点

2.暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态

3.撤销偏向锁,恢复到无锁(标志位为 01)或轻量级锁(标志位为 00)的状态。

关闭偏向锁的延迟

        偏向锁在Java 6之后是默认启用的,但在应用程序启动几秒钟之后才激活,可以使用- XX:BiasedLockingStartupDelay=0 参数关闭延迟,如果确定应用程序中所有锁通常情况下处于竞争状态,可以通过XX:-UseBiasedLocking=false 参数关闭偏向锁。

偏向锁的好处

        偏向锁是在只有一个线程执行同步块时进一步提高性能,适用于一个线程反复获得同一锁的情况。偏向  锁可以提高带有同步但无竞争的程序性能。

        它同样是一个带有效益权衡性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问比如线程池,那偏向模式就是多余的。

轻量级锁

        轻量级锁是JDK  6之中加入的新型锁机制,它名字中的“轻量级”是相对于使用monitor的传统锁而言的, 因此传统的锁机制就称为“重量级”锁。首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的。

        引入轻量级锁的目的:在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。

轻量级锁的原理

        当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步  骤如下: 获取锁

        1.判断当前对象是否处于无锁状态(hashcode、0、01),如果是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark  Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),将对象的Mark Word复制到栈帧中的Lock Record中,将Lock Reocrd中的owner指向当前对象。

        2.JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00,执行同步操作。

        3.如果失败则判断当前对象的Mark  Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态。

轻量级锁的释放

        轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:

        1.取出在获取轻量级锁保存在Displaced Mark Word中的数据。

        2.用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功。

        3.如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要将轻量级锁需要膨胀升级为重量级锁。

        对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁  比重量级锁更慢。

轻量级锁的好处

在多线程交替执行同步块的情况下,可以避免重量级锁引起的性能消耗

小结

轻量级锁的原理是什么?

将对象的Mark Word复制到栈帧的Lock Record中,Mark Word更新为指向Lock Record的指针。

轻量锁的好处是什么?

在多线程交替执行同步块的情况下,可以避免重量级锁引起的性能消耗。

自旋锁

自旋锁的原理

        我们讨论monitor实现锁的时候,知道monitor会阻塞和唤醒线程,线程的阻塞和唤醒需要CPU从用户态转为内核态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,这些操作给系统的并发性能带来了很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间阻塞和唤醒线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋) , 这项技术就是所谓的自旋锁。

        自旋锁在JDK 1.4.2中就已经引入 ,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK  6中就已经改为默认开启了。自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长。那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,用户可以使用参数-XX : PreBlockSpin来更改。

适应性自旋锁

        在JDK 6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持  有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持  续相对更长的时间,比如100次循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控  信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虛拟机就会变得越来越“聪明”了。

锁消除

        锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享  数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是程序员自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下要求同步呢?实际上有许多同步措施并不是程序员自己加入的,同步的代码在Java程序中的普遍程度也许超过了大部分读者的想象。下面这段非常简单的代码仅仅是输出3个字符串相加的结果。无论是源码字面上还是程序语义上都没有同步。

public class Demo1{
    public static void main(String[] args){
        contactString("aa","bb","cc");
    }

    public static String contactString (String s1, String s2, String  s3){
        return new StringBuffer().append(s1),append(s2).append(s3).toString;
    }

}

        StringBuffer的append ()是一个同步方法,锁就是this也就是(new StringBuilder())。虚拟机发现它的动态作用域被限制在concatString()方法内部。也就是说, new StringBuilder()对象的引用永远不会”逃逸"到concatString ( )方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。

锁粗化

        JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样只需要加一次锁即可。

Synchronized优化

1.减少synchronized的范围

同步代码块中尽量短,减少同步代码块中代码的执行时间,减少锁的竞争。

2.降低synchronized锁的粒度

将一个锁拆分为多个锁提高并发度

Hashtable的锁

ConcurrentHashMap的锁

3.读写分离

读取时不加锁,写入和删除时加锁

ConcurrentHashMap,CopyOnWriteArrayList和CopyOnWriteSet

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值