synchronized

1.synchronized是干嘛的?

synchronized是java里面的关键字

synchronized 方法为防止线程干扰和内存一致性错误提供了一个简单的策略:如果一个对象对多个线程可见,则通过同步方法完成对该对象变量的所有读取或写入操作。

简而言之,当你有两个线程正在读写相同的'资源'时,比如说一个名为变量的变量foo,你需要确保这些线程以同步的方式访问变量。如果没有synchronized关键字,线程1可能看不到更改线程2 foo,或者更糟糕的是,它可能只有一半被更改。这不会是你所期望的。
synchronized修饰的方法或者代码块只要前一个线程的执行没有完成,就阻塞下一个线程对方法该方法的调用。线程一次可以访问这个方法。如果没有synchronized全部线程可以同时访问这个方法

2.synchronized怎么用?

synchronized可以加在代码块上,方法上,静态代码块上

加在代码块上需要一个对象作为锁对象,作用范围是被包围的代码块,在代码块之外的代码是可以正常运行的(如果当前锁对象是当前类的class的话,那么和静态方法加锁的锁对象是一样的)

加在方法上作用范围是该方法体里面所有的代码,这是锁对象就是该类的实例

静态方法上锁对象用的是该类的class对象,而不是实例对象

所以当一个类的两个实例同时调用该类同一个非静态被synchronized修饰的方法的话不会造成同步阻塞,但是调用该类被synchronized修饰的静态方法的时候会造成阻塞同步(即便不是同一个静态方法,不同的静态方法,只要被synchronized修饰了,就都会阻塞同步,因为他们的锁对象是一样的,都是当前类的class对象)

3.synchronized的特性

原子性:就是指一个操作或者多个操作要么全部不执行,要么全部执行,并且执行过程中不会被任何因素打断,被synchronized修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放,这中间的过程无法被中断(除了已经废弃的stop()方法),即保证了原子性。

可见性:指的是多个线程访问一个资源时,该资源的状态,值信息等对其他线程都是可见的

synchronized和volatile都具有可见性,其中synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到主存当中,保证资源变量的可见性,如果某个线程占用了该锁,其他线程就必须在锁池中等待锁的释放。

而volatile的实现类似,被volatile修饰的变量,每当值需要修改时都会立即更新主存,主存是共享的,所有线程可见,所以确保了其他线程读取到的变量永远是最新值,保证可见性。        

有序性:synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。

可重入性:当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请锁。

4.synchronized底层实现

了解底层实现之前,先要了解一下java的对象结构,java的对象结构主要分为三个部分:对象头,对象体,对齐字节,其中对象头使我们要关注的重点

对象头是实现锁的基础,因为synchronized申请锁,上锁,释放锁都与其有关系,,对象头主要有markword和class metadata address组成,其中mark word存储对象的hash code,锁信息或分代年龄,或GC标志等,class metadata address是类型指针指向对象的类元数据,JVM通过该指针确定该对象是哪个类的实例

锁也分不同的状态,jdk6之前只有两个状态:无锁和有锁(重量级锁),而在jdk6之后对synchronized进行了优化,新增了两种状态:偏向锁,轻量级锁,锁类型和状态都在对象头mark word里面都有记录,在申请锁,锁升级过程中JVM都需要读取对象的Mark word数据

在对象头里面锁的状态表示:

通过上面可以看到无锁状态和偏向锁状态时有1bit位来表示偏向锁位,锁标志位是01,轻量级锁(自旋锁)和重量级锁只有锁标志位,分别是:00,10代表

每个对象都有一个 monitor 与之关联,monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的, ObjectMonitor里面有几个比较重要的字段:_count,记录获取锁的次数,_owner当前获取锁的线程对象,_WaitSet,处于wait状态的线程,会被加入到_WaitSet,_EntryList,处于等待锁block状态的线程,会被加入到该列表

多个线程同时访问一段同步代码的时候,首先会进入_EntryList集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)

方法块的synchronized同步,底层依赖monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。

方法级别的同步是隐式的,无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。

5.synchronized底层优化

从上面我们可以了解到synchronized的实现原理以及底层是怎么实现的,但是在早期的java里面synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因,后来java官方在1.6之后对synchronized进行了优化,引入了轻量级锁,偏向锁减少释放锁和获取锁带来的性能损耗

锁升级顺序:无锁---偏向锁---轻量级锁----重量级锁

偏向锁:一个线程反复的去获取/释放一个锁,如果这个锁是轻量级锁或者重量级锁,不断的加解锁显然是没有必要的,造成了资源的浪费。于是引入了偏向锁,偏向锁在获取资源的时候会在资源对象上记录该对象是偏向该线程的,偏向锁并不会主动释放,这样每次偏向锁进入的时候都会判断该资源是否是偏向自己的,如果是偏向自己的则不需要进行额外的操作,直接可以进入同步操作。

1.线程A第一次访问同步块时,先检测对象头Mark Word中的标志位是否为01,依此判断此时对象锁是否处于无所状态或者偏向锁状态(匿名偏向锁);

2.然后判断偏向锁标志位是否为1,如果不是,则进入轻量级锁逻辑(使用CAS竞争锁),如果是,则进入下一步流程;

3.判断是偏向锁时,检查对象头Mark Word中记录的Thread Id是否是当前线程ID,如果是,则表明当前线程已经获得对象锁,以后该线程进入同步块时,不需要CAS进行加锁,只会往当前线程的栈中添加一条Displaced Mark Word为空的Lock Record中,用来统计重入的次数
退出同步块释放偏向锁时,则依次删除对应Lock Record,但是不会修改对象头中的Thread Id(这样做的好处就是当前线程下次再获取这个锁的时候就不用再进行CAS加锁操作了,只需要判断一下就可以了);

注:偏向锁撤销是指在获取偏向锁的过程中因不满足条件导致要将锁对象改为非偏向锁状态,而偏向锁释放是指退出同步块时的过程。

4.如果对象头Mark Word中Thread Id不是当前线程ID,则进行CAS操作,企图将当前线程ID替换进Mark Word。如果当前对象锁状态处于匿名偏向锁状态(可偏向未锁定),则会替换成功(将Mark Word中的Thread id由匿名0改成当前线程ID,在当前线程栈中找到内存地址最高的可用Lock Record,将线程ID存入),获取到锁,执行同步代码块;

5.如果对象锁已经被其他线程占用,则会替换失败,开始进行偏向锁撤销,这也是偏向锁的特点,一旦出现线程竞争,就会撤销偏向锁;

6.偏向锁的撤销需要等待全局安全点(safe point,代表了一个状态,在该状态下所有线程都是暂停的),暂停持有偏向锁的线程,检查持有偏向锁的线程状态(遍历当前JVM的所有线程,如果能找到,则说明偏向的线程还存活),如果线程还存活,则检查线程是否在执行同步代码块中的代码,如果是,则升级为轻量级锁,进行CAS竞争锁;

注:每次进入同步块(即执行monitorenter)的时候都会以从高往低的顺序在栈中找到第一个可用的Lock Record,并设置偏向线程ID;每次解锁(即执行monitorexit)的时候都会从最低的一个Lock Record移除。所以如果能找到对应的Lock Record说明偏向的线程还在执行同步代码块中的代码。

7.如果持有偏向锁的线程未存活,或者持有偏向锁的线程未在执行同步代码块中的代码,则进行校验是否允许重偏向,如果不允许重偏向,则撤销偏向锁,将Mark Word设置为无锁状态(未锁定不可偏向状态),然后升级为轻量级锁,进行CAS竞争锁;

8.如果允许重偏向,设置为匿名偏向锁状态,CAS将偏向锁重新指向线程A(在对象头和线程栈帧的锁记录中存储当前线程ID);

9.唤醒暂停的线程,从安全点继续执行代码。

批量重偏向,批量重撤销

注:禁行偏向锁:-XX:-UseBiasedBlocking禁用偏向锁,这是进入synchronized代码块,自动升级为轻量级锁

偏向锁默认是延迟的,不会在程序运行时立即生效,如果避免延迟,可以加VM参数-XX:BiasedLockingStartupDelay=0来禁用延迟

偏向锁解锁之后,线程ID仍然存储于对象头中。

偏向锁撤销可能会导致STW(但是这里的STW我理解不是全局的,可能只是涉及到的线程的STW)偏向锁撤销导致stw_CrazySnail_x的博客-CSDN博客_stw 偏向锁

在高并发系统中最好禁用偏向锁

jvm开启/关闭偏向锁

  • 开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
  • 关闭偏向锁:-XX:-UseBiasedLocking

查看停顿–安全点停顿日志:

要查看安全点停顿,可以打开安全点日志,通过设置JVM参数 -XX:+PrintGCApplicationStoppedTime 会打出系统停止的时间,添加-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1 这两个参数会打印出详细信息,可以查看到使用偏向锁导致的停顿,时间非常短暂,但是争用严重的情况下,停顿次数也会非常多;

注意:安全点日志不能一直打开:  1. 安全点日志默认输出到stdout,一是stdout日志的整洁性,二是stdout所重定向的文件如果不在/dev/shm,可能被锁。  2. 对于一些很短的停顿,比如取消偏向锁,打印的消耗比停顿本身还大。  3. 安全点日志是在安全点内打印的,本身加大了安全点的停顿时间。

所以安全日志应该只在问题排查时打开。  如果在生产系统上要打开,再再增加下面四个参数:  -XX:+UnlockDiagnosticVMOptions -XX: -DisplayVMOutput -XX:+LogVMOutput -XX:LogFile=/dev/shm/vm.log  打开Diagnostic(只是开放了更多的flag可选,不会主动激活某个flag),关掉输出VM日志到stdout,输出到独立文件,/dev/shm目录(内存文件系统)。

此日志分三部分:  第一部分是时间戳,VM Operation的类型  第二部分是线程概况,被中括号括起来  total: 安全点里的总线程数  initially_running: 安全点开始时正在运行状态的线程数  wait_to_block: 在VM Operation开始前需要等待其暂停的线程数

第三部分是到达安全点时的各个阶段以及执行操作所花的时间,其中最重要的是vmop

  • spin: 等待线程响应safepoint号召的时间;
  • block: 暂停所有线程所用的时间;
  • sync: 等于 spin+block,这是从开始到进入安全点所耗的时间,可用于判断进入安全点耗时;
  • cleanup: 清理所用时间;
  • vmop: 真正执行VM Operation的时间。

可见,那些很多但又很短的安全点,全都是RevokeBias, 高并发的应用会禁用掉偏向锁。

轻量级锁:偏向锁考虑的是有同步无竞争时程序的效率,而轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。

轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁; 

加锁过程:

1.在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。

2.拷贝对象头中的Mark Word复制到锁记录中;

3.拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。

4.如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态

5.如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

轻量级锁的释放:

释放锁线程视角:由轻量锁切换到重量锁,是发生在轻量锁释放锁的期间,之前在获取锁的时候它拷贝了锁对象头的markword,在释放锁的时候如果它发现在它持有锁的期间有其他线程来尝试获取锁了,并且该线程对markword做了修改,两者比对发现不一致,则切换到重量锁。

因为重量级锁被修改了,所有display mark word和原来的markword不一样了。

怎么补救,就是进入mutex前,compare一下obj的markword状态。确认该markword是否被其他线程持有。

此时如果线程已经释放了markword,那么通过CAS后就可以直接进入线程,无需进入mutex,就这个作用。

尝试获取锁线程视角:如果线程尝试获取锁的时候,轻量锁正被其他线程占有,那么它就会修改markword,修改重量级锁,表示该进入重量锁了。

还有一个注意点:等待轻量锁的线程不会阻塞,它会一直自旋等待锁,并如上所说修改markword。

这就是自旋锁,尝试获取锁的线程,在没有获得锁的时候,不被挂起,而转而去执行一个空循环,即自旋。在若干个自旋后,如果还没有获得锁,则才被挂起,获得锁,则执行代码。

注:自旋的次数在jdk1.6的时候是由参数可以指定的,但是在1.7之后就是由系统自己判断的了,自适应自旋锁

重量级锁:

重量级锁利用操作系统中的mutex互斥锁,由轻量级锁升级而来,为锁对象申请Monitor锁,让锁对象之前记录轻量级锁lock record地址的地方指向monito对象,
monitor对象里面主要包含entrylist,waitset,owner
owner指向之前轻量级锁的持有对象,当前线程自己进入entrylist进行等待

之前持有锁对象的线程执行完毕,使用cas将mark word值恢复的时候失败了,这是发现锁升级了,那么就会根据锁对象找到monito对象,将owner设置为空,
并唤醒waitset,entrylist里面的线程
waitset里面主要是获取锁的线程代码调用wait方法时进入这个队列
当前线程每次获取重量级锁都有一个计数器会+1,退出计数器就会-1,这样当计数器为0的时候锁就释放了,释放锁的时候会将唤醒waitset和entrylist里面所有的线程

 

可重入锁:

比如在一个线程里面对同一个锁获取两次的话,如果不可重入的话,那么第二次获取该锁的时候就会造成死锁
可重入锁的原理:
1.偏向锁:根据存储的线程ID来判断的
2.轻量锁:如果锁的对象头地址在当前线程的栈帧内那就代表可以重入
3.重量锁:根据monitor里面的owner进行判断的

锁消除:

指的是编译器的优化,当前代码如果被synchronized包围的话,但是编译器判断不会发生多线程竞争的情况的话,那就会自动将锁消除了

锁粗化:

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

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

比如在一个for循环里面加锁的话,那编译器就会将锁粗化,将锁放到for循环外面进行加锁,这样只用获取一次锁就行了

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值