手记

线程安全与锁优化

1 线程安全

当多个线程访问一个对象时,如果不用考虑这些线程在运行环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象是线程安全的

1.1  Java语言中的线程安全

按照线程安全的“安全程度”由强至弱来排序,我们可以将Java语言中各种操作共享的数据分为

  • 不可变(Immutable)
    不可变的对象一定是线程安全的。
    保证对象行为不影响自己状态的途径有很多种,最简单的就是把对象中带有状态的变量都声明为final
    Java API中符合不可变要求的类型:String,java.lang.Number的部分子类(如Long和Double的数值包装类,BigInteger和BigDecimal等大数据类型但AtomicIntegerAtomicLong则并非不可变的)

  • 绝对线程安全
    满足线程安全
    Java API中标注自己是线程安全的类,大多数都不是绝对线程安全的。

  • 相对线程安全
    就是我们通常意义上所讲的线程安全,需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
    Java语言中,大部分的线程安全都属于这种类型,例如Vector,HashTable,Collections的synchronizedCollection()方法包装的集合等。

  • 线程兼容
    对象本身并不是线程安全的,但是通过使用同步手段来保证对象在并发环境中可以安全的使用。Java API中大部分的类都是属于线程兼容的,如ArrayList和HashMap

  • 线程对立
    无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码
    一个线程对立的例子就是Thread类的suspend()和resumn()方法(已被JDK声明废弃了)。
    常见的线程对立操作还有System.setIn(), System.setOut(), System.runFinalizersOnExit()等等。

1.2 线程安全的实现方法

1.2.1 互斥同步

互斥是实现同步的一种手段
临界区、互斥量和信号量都是主要的互斥实现方式

Java中,最基本的互斥同步手段就是synchronized关键字。通过锁计数器+-1,实现对锁的加锁和释放。

synchronized是一个重量级的操作,因为:Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要消耗很多的处理器时间。对于代码简单的同步块(如synchronized修饰的getter()和setter方法),状态转换消耗的时间有可能比用户代码执行的时间还要长。而虚拟机本身也会进行一些优化,比如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态之中。

还可使用juc包中的ReentrantLock(重入锁)来实现同步:JDK1.5多线程环境下synchronized的吞吐量下降的很严重,而ReentrantLock则基本保持在同一个比较稳定的水平上。JDK 1.6之后两者性能基本持平。

虚拟机在未来的性能改进中还会更偏向于原生的synchronize的,所以还是提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。

1.2.2 非阻塞同步

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此也称为阻塞同步
从处理问题的方式上说,互斥同步属于一种悲观的并发策略。随着硬件指令集的发展,
因为需要操作和冲突检测具有原子性,靠的就是硬件来完成这件事情,保证一个从语义看起来需要多次操作的行为只通过一条处理器指令就能完成

  • 测试并设置

  • 获取并增加

  • 交换

  • 比较并交换

  • 加载链接/条件存储

我们可以采用基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。

1.2.3 无同步方案

如果一个方法本来就不设计共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的。

  • 可重入代码(Reentry code)
    也叫纯代码(Pure code),可在代码执行任意时刻中断它,转而去执行另外一段代码,控制权返回后,原来的程序不会出现任何错误
    可重入代码有一些共同的特征。例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。
    我们可以通过一个简单的原则来判断代码是否具备可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

  • 线程本地存储:这个可以用ThreadLocal来设置。

2 锁优化

2.1 自旋锁与自适应自旋

引入的原因是互斥同步对性能最大的影响是阻塞,挂起线程和恢复线程都需要转入内核态完成,给并发性能带来很大压力。
自旋锁让物理机器有一个以上的处理器的时候,能让两个或以上的线程同时并行执行。我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

自旋锁的自旋次数默认值是10次,用户可以使用参数-XX:PreBlockSpin来更改。

JDK1.6引入了自适应的自旋锁。自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。(这个应该属于试探性的算法)。

2.2 锁消除

JIT在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除
主要判定依据是逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据,认为它们是线程私有的,同步加锁自然就无需进行。

2.3 锁粗化

当多个彼此靠近的同步块可以合并到一起,形成一个同步块的时候,就会进行锁粗化。该方法还有一种变体,可以把多个同步方法合并为一个方法。如果所有方法都用一个锁对象,就可以尝试这种方法。

2.4 轻量级锁

2.5 偏向锁

大多数锁,在它们的生命周期中,从来不会被多于一个线程所访问。即使在极少数情况下,多个线程真的共享数据了,锁也不会发生竞争。为了理解偏向锁的优势,我们首先需要回顾一下如何获取锁(监视器)。

获取锁的过程分为两部分。首先,你需要获得一份契约.一旦你获得了这份契约,就可以自由地拿到锁了。为了获得这份契约,线程必须执行一个代价昂贵的原子指令。释放锁同时就要释放契约。根据我们的观察,我们似乎需要对一些锁的访问进行优化,比如线程执行的同步块代码在一个循环体中。优化的方法之一就是将锁粗化,以包含整个循环。这样,线程只访问一次锁,而不必每次进入循环时都进行访问了。但是,这并非一个很好的解决方案,因为它可能会妨碍其他线程合法的访问。还有一个更合理的方案,即将锁偏向给执行循环的线程。

将锁偏向于一个线程,意味着该线程不需要释放锁的契约。因此,随后获取锁的时候可以不那么昂贵。如果另一个线程在尝试获取锁,那么循环线程只需要释放契约就可以了。Java 6的HotSpot/JIT默认情况下实现了偏向锁的优化。



作者:芥末无疆sss
链接:https://www.jianshu.com/p/2fa47d5a13bb
來源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。


0人推荐
随时随地看视频
慕课网APP