小伙伴们都接触过线程,也都会使用线程,今天咱们要讲的是线程安全相关的内容,在这以前咱们先来看一个简单的代码案例。
代码案例:java
/** * @url: i-code.online * @author: AnonyStar * @time: 2020/10/14 15:39 */ public class ThreadSafaty { //共享变量 static int count = 0; public static void main(String[] args) { //建立线程 Runnable runnable = () -> { for (int i = 0; i < 5; i++) { count ++; try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } } }; for (int i = 0; i < 100; i++) { new Thread(runnable,"Thread-"+i).start(); } try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("count = "+ count); } }
执行结果:
问题说明:
在上面的代码中咱们能够看到,定义了一个线程 runnable
里面对公共成员变量进行 ++
操做,并循环五次,每次睡眠一毫秒,以后咱们在主线程 main
方法中建立一百个线程而且启动,而后主线程睡眠等待五秒以此来等全部的线程执行结束。咱们预期结果应该是 500
。可是实际执行后咱们发现 count
的值是不固定的 ,是小于 500
的,这里就是多线程并行致使的数据安全性问题!
c++
经过上述案例咱们能够清楚的看到线程安全的问题,那么咱们想一想是否有什么办法来避免这种安全问题尼 ?咱们能够想到致使这种安全问题的缘由是由于咱们访问了共享数据,那么咱们是否能将线程访问共享数据的过程变成串行的过程那么不就是不存在这个问题了。这里咱们能够想到以前说的锁
,咱们知道锁是处理并发的一种同步方式,同时他也具有互斥性,在Java中实现加锁是经过synchronized
关键字
在Java
中咱们知道有一个元老级的关键字 synchronized
,它是实现加锁的关键,可是咱们一直都认为它是一个重量级锁,其实早在 jdk1.6
时就对其进行了大量的优化,让它已经变成很是灵活。也再也不一直是重量级锁了,而是引入了 偏向锁 和 轻量级锁。 关于这些内容咱们将详细介绍。
数组
synchronized
修饰实例方法,做用于当前实例加锁synchronized
修饰静态方法,做用于当前类对象加锁,synchronized
修饰代码块,指定加锁对象,对给定对象加锁,
在上述状况中,咱们要进入被
synchronized
修饰的同步代码前,必须得到相应的锁,其实这也体现出来针对不一样的修饰类型,表明的是锁的控制粒度
synchronized
关键字让其实现线程安全//建立线程 Runnable runnable = () -> { synchronized (ThreadSafaty.class){ for (int i = 0; i < 5; i++) { count ++; try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } } } };
只须要添加
synchronized (ThreadSafaty.class)
的修饰,将操做的内容放入代码块中,那么就会实现线程安全
synchronized
的做用,这是咱们平时开发中常规使用,你们有没有过疑问,这个锁究竟是怎么存储实现的?那么下面咱们将对探索其中的奥秘Mutual Exclusion
)的 ,那么它是在什么地方标记存在的尼?synchronized
它获取锁的过程究竟是怎么样的呢?它的锁是如何存储的呢?synchronized
的语法,能够看到 synchronized(lock)
是基于 lock
的生命周期来实现控制锁粒度的,这里必定要理解,咱们得到锁时都时一个对象,那么锁是否是会和这个对象有关系呢?jvm
中的分布形式,再来看锁是怎么被实现的。Heap
中的布局,而不会涉及过多的关于对象的建立过程等细节,这些内容咱们再单独文章详细阐述,能够关注 i-code.online
博客或wx "云栖简码"
hotspot
中对象在内存中的分布能够分为三个部分:对象头(Header
)、实列数据(Instance Data
)、对其填充(Padding
)hashcode
、GC分代年龄、锁标记状态、偏向锁持有线程id、线程持有的锁(monitor
)等六个内容,这部分数据的长度在 32 位和64位的虚拟机中分别为32bit 和 64bit,在官方将这部分称为 Mark Word
。Mark Word
实际是一中能够动态定义的数据结构,这样可让极小的空间存储尽可能多的数据,根据对象的状态复用本身的内存空间,好比在32位的虚拟机中,若是对象未被同步锁锁定的状态下, Mark Word
的32个比特存储单元中,25个用于存储哈希码,4个用于存储GC分代年龄,2个存锁标记位,1个固定位0,针对各个状态下的分布能够直观的参看下面的图表
32位HotSpot
虚拟机对象头Mark Word安全
锁状态 | 25bit | 4bit | 1bit (是不是偏向锁) |
2bit (锁标志位) |
|
---|---|---|---|---|---|
23bit | 2bit | ||||
无锁 | 对象的HashCode | 分代年龄 | 0 | 01 | |
偏向锁 | 线程ID | Epoch(偏向时间戳) | 分代年龄 | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 | |||
重量级锁 | 指向重量级锁的指针 | 10 | |||
GC标记 | 空 | 11 |
上述说的是32位虚拟机,须要注意。关于对象头的另外一部分是类型指针,这里咱们不展开再细说了,想了解的关注
i-code.online
,会持续更新相关内容😁😊
云栖简码
。Hotspot
中实现 Mark Word
的代码在 markOop.cpp
中,咱们能够看下面片断,这是描述了虚拟机中MarkWord
的存储布局:new
一个对象时,虚拟机层面实际会建立一个 instanceOopDesc
对象,咱们熟悉的 Hotspot
虚拟机采用了 OOP-Klass
模型来描述 Java
对象实例,其中 OOP
就是咱们熟悉的普通对象指针,而 Klass
则是描述对象的具体类型,在Hotspot
中分别用 instanceOopDesc
和 arrayOopDesc
来描述,其中arrayOopDesc
用来描述数组类型,instanceOopDesc
的实现咱们能够从 Hotspot
源码中找到。对应在 instanceOop.hpp
文件中,而相应的 arrayOopDesc
在 arrayOop.hpp
中,下面咱们来看一下相关的内容:instanceOopDesc
继承了 oopDesc
,而 oopDesc
则定义在 oop.hpp
中,_mark
的实现定义了,以下,咱们看到它是markOopDesc
markOopDesc
的定义在 markOop.hpp
文件中,以下图所示:markOop
中存储项,因此在咱们实际开发时,当 synchronized
将某个对象做为锁时那么以后的一系列锁的信息都和 markOop
相关。如上面表格中 mark word
的分布记录所示具体的各个部分的含义native
的c++
对象 oop/oopdesc
来映射的,而每一个对象都带有一个 monitor
的监视器对象,能够在 markOop.hpp
中看到,其实在多线程中抢夺锁就是在争夺 monitor
来修改相应的标记Java
中 synchronized
是实现互斥同步最基本的方法,它是一个块结构(Block Structured
)的同步语法,在通过javac
编译后会在块的先后分别造成 monitorrenter
和 monitorexit
两个字节码指令,而它们又都须要一个 reference
类型的参数来指明锁对象,具体锁对象取决于 synchronized
修饰的内容,上面已经说过不在阐述。《深刻理解Java虚拟机》中有这样的描述:
根据《Java虚拟机规范》的要求,在执行monitorenter指令时,首先要去尝试获取对象的锁。若是 这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增长一,而在执行 monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。若是获取对象 锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止
synchronized
修饰的代码块对同一个线程是可重入的,这也就避免了同线程反复进入致使死锁的可能synchronized
修饰的代码块直接结束释放锁以前,会阻塞后面的其余线程Heavy-Weight
)的操做过程,由于在Java
中线程都是映射到操做系统的原生内核线程上的,若是要阻塞和唤醒某一个线程都须要通过操做系统来调度,而这就不可避免的会进行用户态和内核态的转换,可是这种转换是很是耗费处理器时间的,尤为对于自己业务代码简单的程序,可能在这里耗费的时间比业务代码自身执行的时间还长,因此说synchronized
是一个重量级的操做,不过在 jdk6
后对其作了大量的优化,让它再也不显得那么重JDK5
升级到 JDK6
后进行一系列关于锁的改进,经过多种技术手段来优化锁,让 synchronized
再也不像之前同样显的很重,这其中涉及到适应性自旋(Adaptive Spinning
)、锁消除(Lock Elimination
)、锁膨胀(Lock Coarsening
)、轻量级锁(LightWeight Locking
)、偏向锁(Biased Locking
)等,这些都是用来优化和提升多线程访问共享数据的竞争问题。java
内部就有大量的存在,好比下面这个典型的例子,下面展现的是字符串的相加private String concatString(String s1,String s2,String s3){ return s1 + s2 + s3; }
String
类是被 final
修饰的不可变类,因此对于字符串的相加都是经过生成新的String
对象来试试先的,所以编译器会对这种操做作优化处理,在JDK5
以前会转换为 StringBuffer
对象的append()
操做,而在JDK5
及其以后则转换为StringBuilder
对象来操做。因此上述代码在jdk5
可能会变成以下:private String concatString(String s1,String s2,String s3){ StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); }
StringBuffer。append()
方法是一个同步方法,带有同步快,锁对象就是 sb
,这时候虚拟机经过分析发现 sb
的做用域被限制在方法内部,而不可能逃逸出方法外让其余线程访问到,因此这是在通过服务端编译器的即时编译后,这段代码的全部同步措施都会失效而直接执行。上述代码是为了方便演示而选择了String,实际来讲在jdk5以后都是转换为Stringbuilder ,也就不存在这个问题了,可是在jdk中相似这种仍是很是多的。
String
的案例,在连续的append
操做都是零碎的同步块,并且都是同一个锁对象,这时候会将锁的范围扩展,到整个操做序列外部,也就是第一个append
以前到最后一个append
操做以后,将这些所有放入一个同步锁中就能够了,这样就避免了屡次的锁获取和释放。CPU
的执行时间。等待观察持有锁的线程是否能很快的释放锁,其实这个等待就比如是一个空的循环,这种技术就是一个所谓的自旋锁JDK6
中及已是默认开启的了,在jdk4
时就引入了。自旋锁并非阻塞也代替不了阻塞。CPU
时间的,虽然它避免了线程切换的开销,可是这之间时存在平衡关系的,假如锁被占用的时间很短那么自旋就很是有价值,会节省大量的时间开销,可是相反,若是锁占用的时间很长,那么自旋的线程就会白白消耗处理器资源,形成性能的浪费。-XX: PreBlockSpin
参数来自定义设置JDK6
以后引入了自适应自旋锁,也就是对原有的自旋锁进行了优化JDK6
时加入的新的锁机制,它的轻量级是相对于经过操做系统互斥量来实现的传统锁而言的,轻量级锁也是一种优化,而不是能替代重量级锁,轻量级锁的涉及初衷就是在没有多线程竞争下减小传统重量级锁使用操做系统互斥量产生的性能消耗。Heap
中的分布了解,也就是上面说到的内容。01
状态,那么虚拟机首先将在当前线程的栈帧中创建一个名为锁记录Lock Record
的空间Mark Word
的拷贝,官方给其加了个Displaced
的前缀,即 Displaced Mark Word
,以下图所示,这是在CAS
操做以前堆栈与对象的状态CAS
操做尝试把对象的Mark Word
更新为指向Lock Record
的指针,若是更新成功则表明该线程拥有了这个对象的锁,而且将Mark Word
的锁标志位(最后两个比特)转变为 “00”,此时表示对象处于轻量级锁定状态,此时的堆栈与对象头的状态以下:Mark Word
是否指向当前线程的栈帧,若是是,则说明当前线程已经拥有了这个对象的锁,那么直接进入同步代码块执行便可。不然则说明这个对象已经被其余线程抢占了。Mark Word
中存储的就是指向重量级锁的指针,等待的线程也必须进入阻塞状态CAS
操做来进行的Mark Word
仍然指向线程的锁记录,那么就用CAS
操做把对象当前的 Mark Word
和线程中复制的 Displaced Mark Word
替换回来轻量级锁适用的场景是对于绝大部分锁在整个同步周期内都是不存在竞争的,由于若是没有竞争,轻量级锁即可以经过CAS
操做成功避免了使用互斥量的开销,可是若是确实存在锁竞争,那么除了互斥量自己的开销外还得额外发生了CAS
操做的开销,这种状况下反而比重量级锁更慢
JDK6
引入的一种锁优化技术,若是说轻量级锁是在无竞争状况下经过CAS
操做消除了同步使用的互斥量,那么偏向锁则是再无竞争状况下把整个同步都给消除掉了,连CAS
操做都再也不去作了,能够看出这比轻量级锁更加轻Epoch
两个内容CAS
操做来将这个帧的线程ID记录到对象头中,若是CAS
成功了。则持有锁对象的线程再以后进入同步代码再也不进行任何同步操做(如获取锁解锁等操做)。每次都会经过判断当前线程与锁对象中记录的线程id是否一致。CAS
操做失败了,那说明确定存在另一个线程在获取这个锁,而且获取成功了。这种状况下说明存在锁竞争,则偏向模式立刻结束,偏向锁的撤销,须要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,会根据锁对象是否处于锁定状态来决定是否撤销偏向也就是将偏向锁标志位改成“0”,若是撤销则会变为未锁定(“01”)或者轻量级锁(“00”)JDK6
及其以后是默认启用的。因为偏向锁适用于无锁竞争的场景,若是咱们应用程序里全部的锁一般状况下处于竞争状态,能够经过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false
,那么程序默认会进入轻量级锁状态。-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
synchronized
的代码,咱们经过字节码工具能够看到右侧窗口。咱们发现,在同步代码块的先后分别造成了monitorenter
和 monitorexit
两条指令monitor
的监视器,这里的monitorenter
指令就是去获取一个对象的监视器。而相应的monitorexit
则表示释放监视器monitor
的全部权,容许被其余线程来获取monitor
是依赖于系统的 MutexLock
(互斥锁) 来实现的,当线程阻塞后进入内核态事,就会形成系统在用户态和内核态之间的切换,进而影响性能synchronized
锁的一些优化与转换,在咱们开启偏向锁和自旋时,锁的转变是 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,CAS
操做,可是偏向锁中只有在第一次时才会CAS
操做MarkWord
中没有哈希值本文由AnonyStar 发布,可转载但需声明原文出处。
欢迎关注微信公帐号 :云栖简码 获取更多优质文章
更多文章关注笔者博客 : 云栖简码 i-code.online