深入理解java虚拟机系列第三版读后小记:(十五)java线程安全

前言

本文介绍一下java的线程安全的相关知识

线程安全

所谓线程安全即多个线程同时访问一个对象时,不考虑这些线程运行环境下的调度和交替执行,也不需要额外的同步,或者调用方进行任何的协调操作,调用这个对象的行为都可以获取正确结果,即可认为该线程是安全的。

线程安全的“排序”

通常java中线程“安全程度”由高到低体现主要分为以下五类:

  • 不可变 final修饰的对象即不可变,这是线程安全的,无需再做任何安全性保障
  • 绝对线程安全 :意味着对象任何操作都是线程安全的,这就需要对象本身要有安全性保障之外,其调用方也有安全性保障
  • 相对线程安全:最常用的一种,只需要确保对象单次调用线程安全,调用的时候不需要额外的安全措施,如HashTable, Collection.synchronizedCollection()同步集合等
  • 线程兼容:线程兼容本身并非线程安全,不过调用方通过正确的同步手动确保线程安全,如常用的集合类
  • 线程对立,即不管调用端采取怎样的同步措施,都无法再多线程中并发使用代码,java是个支持多线程的语言,所以线程对立对于java来说弊大于利,很少用到。

线程安全的实现方法

实现线程安全的方法主要有以下几种

  • 互斥同步,又称悲观锁,一种最常见的也最主要的并发安全保障手段,常用的就是synchronize关键字。synchronize关键字编译成字节码后会在同步块的前后生成两个字节码指令,monitorenter和monitorexit。其思想就是执行monitorenter时,要获取当前对象的锁,如果这个对象没被锁定,或者当前对象已经拥有了这把锁,即monitorenter的计数器值+1,执行monitorexit时,计数器值会-1,如果计数器的值为0,即锁被释放。
  • 非阻塞同步,又称乐观锁,无锁。互斥同步是悲观锁的策略,它认为不加锁的话,所有共享数据的操作都会产生竞争,到最后结果都是错误的。而乐观锁就反之,认为共享数据不会产生竞争,如果真的出现竞争,就采取补偿措施,最常用的补偿措施就是重试,直到出现没有竞争的共享数据。所以又称为无锁,最常见的试下就是CAS。
  • 无同步方案:并不是所有线程安全都需要进行安全措施保护,有些代码天生就是安全的,无需任何同步机制。比如可重入代码:又称纯代码,线程安全代码的子集,通常这种代码不依赖全局变量,堆上的数据和公有的系统资源,所用的状态量都来自参数,也不调用不可重入的方法。通常这种方法运行的结果都是可测的,只要输入相同的数据,就能返回相同的结果。而另一种就是线程本地存储,即threadLocal

锁优化

众所周知synchronize是一个重量级锁,jdk6之后就对synchronize进行了很多的优化,分化出很多不同的锁机制

自旋锁和自适应自旋

之前互斥同步的时候,提起过互斥同步的性能消耗最大就是在处理阻塞上,线程挂起和恢复都需要用户态和内核态的切换,所以有很大的性能浪费。而且共享数据持有锁的时间就很短暂,再为它进行挂起和恢复就有些不划算,所以为了让线程不在阻塞,就选择自旋一下,不放弃处理器的执行时间,等待前面的线程释放锁。这就是自旋锁,一版默认自旋次数为10,jdk1.6之后引入了自适应自旋,自旋的次数不再是固定的,而是由一次在同一个锁上的自旋次数和锁持有者状态决定。如果同一个锁对象,自旋等待成功获得锁,并行持有锁对象的线程正在运行,那么虚拟机认为这次自旋也很有可能会成功,允许扩大自旋的次数。另一方面,对于某个锁自旋很少成功,那么虚拟机认为自旋就会失败就缩短自旋次数或者直接放弃自旋

锁消除

锁消除是指代码中有些加锁的同步块在编译期间,虚拟机认为无需加锁也是安全的,就会被锁擦掉。其实现主要依赖于逃逸分析的技术支持,即判断某一段代码,在堆上的所有数据都不会逃逸到被其他线程所访问,那就可以把它们当做栈上数据对待,认为tam是线程私有的,也就无需加锁。
如下

public String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}

这是一段看起来没有同步的代码,String是一个不可变类,所以对字符串操作都是生成一个新的String对象,因此java编译器会对其代码进行优化,jdk1.5之前是转换为StringBuffer对象进行操作

public 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的动态作用域只在concatString()这个方法内,永远不会逃逸到其他地方去,其他线程无法访问它,所以在编译后会擦除掉其中的锁

锁粗化

一般我们会期望同步代码块越小越好,只在共享数据的作用域进行同步,使得需要同步操作数量尽可能减少,等待锁的线程也尽早的能拿到锁。
这样通常情况下也没错,但如果一系列连续操作不停的对同一对象进行加锁解锁,甚至循环体内加锁,即使没有线程竞争,频繁的互斥操作也会造成无必要的性能消耗。

上文中提到的concatString方法就是这样,诸多append操作不停的加锁,虚拟机检测到这样一连串的零碎的操作都对同一个对象加锁,就会将加锁范围扩大到整个操作系列的外部,继续以concatString方法为例,会扩展到第一个append之前到最后一个append之后,这样只需加锁一次无需每个append进行加锁。

轻量级锁

谈到轻量级锁,就必须了解HotSpot对象头的内存布局
对象头其中分为两部分,一部分存储自身数据,如hash码,gc年龄,这个陈伟mark word,另一部分为指向方法区对象类型的数据指针。轻量级锁的主要实现就依赖mark word
在这里插入图片描述
所以其实现流程如下

  • 进入同步代码块之前,先检测同步对象锁的状态,01即为未锁定

  • jvm在当前线程中建立一个栈帧名为锁记录(Lock Record)的空间,用来存储复制的mark world,称为即Displaced Mark Word
    在这里插入图片描述
    轻量级锁CAS操作之前堆栈与对象的状态

  • 虚拟机尝试cas操作将mark world 的指针更新到lock record,成功后修改锁标志位为00,表示对象处于轻量级锁状态

  • 如果失败,就意味着至少一条线程与当前存在竞争关系,虚拟机优先检查对象的mark world是否指向当前线程的栈帧,是,说明当前线程已经拥有了这个对象的锁,直接进入同步块操作。否则就说明锁已经被其他线程抢占了,如果出现两条以上的线程竞争同一个锁,那轻量级锁就失效升级成重量级锁。
    在这里插入图片描述
    轻量级锁CAS操作之后堆栈与对象的状态

解锁也是通过CAS操作,如果对象的mark world扔指向线程的锁记录,那就CAS操作把当前对象的mark world 和线程复制的Displaced Mark Word替换过来,成功即解锁,失败说明有其他线程尝试获取过该锁,需要解锁的时候唤醒其他线程

轻量级锁的依据四整个同步周期内不存在竞争的这个经营法则,如果没有竞争,轻量级通过cas操作避免了互斥的开销,如果明确存在竞争,除互斥开销本身外,还有cas的开销,就比重量级锁还要重。

偏向锁

偏向锁相比轻量级锁来讲,消除同步方面更加彻底,轻量级是消除了互斥操作,而偏向锁连CAS操作也消除了。
其思想为锁会偏向其获取的第一个线程,如果接下来的执行过程中,该锁一直没有被其他线程获取,则持有偏向锁的线程永远无需同步。

其原理也简单,和轻量级锁一样,检查锁状态为01,然后开启偏向模式,即偏向模式值设置为1,使用CAS操作记录获取锁的线程存到mark world中。CAS操作成功,持有偏向锁的线程进行同步块中无需同步。

一旦出现另外一个线程竞争锁的情况,偏向锁即失效,撤销偏向模式,值设置为0,锁状态为01或者00(轻量级锁状态)
在这里插入图片描述
偏向锁、轻量级锁的状态转化及对象Mark Word的关系

总结

本文介绍了线程安全的几种方法以及锁的优化机制,至此,jvm系列算是告一段落。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值