线程安全与锁优化

  1. 云栖社区>
  2. 博客>
  3. 正文

线程安全与锁优化

南方之木 2018-04-07 22:53:02 浏览3039
展开阅读全文

一、线程安全

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

线程安全的代码都必须具备一个特征:代码本身封装了 所有毕业的正确性保障手段,令调用者无须关系多线程的问题,
更无须自己采用任何措施来保证多线程正确调用。

1、java语言中的线程安全

此处讨论线程安全,限定于多个线程之间存在数据访问的前提。

按照线程安全与强到弱排序,将java语言中各种操作共享数据分为5类:

  • 不可变:不可变的对象一定是线程安全的,无论是对象的方式实现还是方法发调用者,都不需要采取任何线程安全保障措施。
    如果共享数据是一个基本数据类型,那么只有在定义时使用final关键字修饰它,就可以保证他是不可变的。

如果共享数据是一个对象,那就要保证对象的行为不会对其自身状态产生任何影响。简单的方式就是把对象中带有状态的变量都声明为final,
这样在构造函数结束之后,他就是不可变的。

  • 绝对线程安全:满足线程安全的定义“不管运行时环境如何,调用者都不需要任何额外的同步措施”
  • 相对线程安全:就是通常意义上讲的线程安全,他需要保证对这个对象单独操作是线程安全的,在调用的时候不需要额外的保证措施,
    对一些特定顺序的连续调用,就可以需要在调用端使用额外的同步手段来保证调用的正确性。

在java中大部分线程安全都属于此类,如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等。

  • 线程兼容:指对象本身不是线程安全的,可以通过调用端正确的使用同步手段保证对象在并发环境中可以安全的使用。
    平时常说的一个类不是线程安全的,绝大多数指的是这一种情况
  • 线程对立:无论调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。

2、线程安全的实现方法

2.1、互斥同步

互斥同步是常见的一种并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用。
互斥是实现同步的一种手段,临界区(Critical Selection)、互斥量(Mutex)、信号量(Semaphore) 都是主要的互斥实现方式。
因此互斥是因,同步是果,互斥是方法同步是目的。

java中最基本的互斥手段就是synchronized关键字,synchronized关键字经过编译后,会在同步块前后分拨形成monitorenter、monitorexit
两个字节码指令,,这两个字节码都需要一个reference类型参数来指明要锁定和解锁的对象。
如果synchronized明确指定了对象参数,那就是这个对象的reference;
如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法来作为所对象。

除了synchronized关键字外,还可以使用java.util.concurrent包中重入锁(ReentrantLock)来实现同步,ReentrantLock与synchronized
具备一样的线程重入特性。ReentrantLock高级特性:

  • 等待可中断:指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情
  • 可实现公平锁:指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来一次获得锁;而非公平锁不保证这一点。synchronized是非公平的
  • 锁绑定多个条件:一个ReentrantLock对象可以同时绑定多个Condition对象。
2.2 非阻塞同步

互斥同步最主要问题就是进行线程阻塞和唤醒时带来的性能问题,这种同步也称为阻塞同步。
非阻塞同步:先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就采取其他补偿措施,
这种乐观的并发策略虚的实现都不需要讲线程挂起。
需要操作和冲突检测具备原子性,通过硬件来完成。语义上需要多次操作的行为只通过一条处理器指令就能完成,如:

  • 测试并设置(Test-and-set)
  • 获取并增加(Fetch-and-increment)
  • 交换(swap)
  • 比较并交换(Compare-and-swap,CAS)
  • 加载连接/条件存储(Load-linked/Store-conditional,LL/SC)

CAS指令需要有3个操作数,分拨是内存地址(V)、旧的预期值(A)、新值(B)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器
用新值B更新V的值,否则就不执行更新;无论是否更新了V,都会返回V的旧值,上述处理过程是一个原则操作。

2.3 无同步方案

如果一个方法本来就不涉及共享数据,就无需任何同步措施保证正确性。

  • 可重入代码:也叫纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来程序不会出错。
    可重入代码特征:不依赖存储在堆上的数据和公用的系统资源、用的状态量都由参数中传入、不调用非可重入的方法等。

判断代码是否具备可重入性:如果一个方法返回结果可预测,只有输入了相同的数据,都能返回相同的几个,那就满足可重入性的要求,也是线程安全的。

  • 线程本地存储
    如果一段代码中所需的数据必须与其他代码共享,并且共享数据的可见范围限制在同一个线程内,那么无需同步也能保证线程之间不会出现数据争用。

如大部分消息队列架构模式:生产者-消费者模式,都将产品消费过程尽量在一个线程中消费完。
经典的Web交互模式中“的一个请求对应一个服务器线程”的处理方法。

3、锁优化

高效并发是jdk 1.5 到 1.6 的一个重要改进,HotSpot虚拟机开发团队在这个版本花费大量精力去实现各种锁优化技术,
如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁,这些技术都是为了在线程之间更高效地共享数据以及解决竞争问题,从而提高程序效率。

1、自旋锁与自适应自旋

互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力;
同时在许多应用上,共享数据的锁状态只会持续很短的时间,为了这段时间去挂起和恢复线程并不值得。如果物理机有一个以上的处理器,
能让多于两个的线程同时并行执行,就可以让后边请求锁的那个线程“稍等一下”,但不放弃处理器执行时间,看看持有锁的线程是否很快就会释放。
为了让线程等待,只需让线程执行一个忙循环(自旋),这项技术就是自旋锁。
存在问题:如果锁被占用时间很短,自旋等待的效果就会很好;反之自旋线程白白消耗处理器资源,带来性能上浪费。
jdk 1.6引入自适应自旋锁,如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,虚拟机就会认为这次自旋
也很有可能再次成功,进而允许自旋等待持续相对更长时间。如果某个锁,自旋很少成功获得,那在以后获取锁时可能忽略自旋过程。

2、锁消除

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

    
      public String concatString(String s1, String s2, String s3) {
            StringBuffer sb = new StringBuffer();
            sb.append(s1).append(s2).append(s3);
            return sb.toString();
        }
        //StringBuffer append源码
      public synchronized StringBuffer append(String str) {
              toStringCache = null;
              super.append(str);
              return this;
          }  
        

如上述代码,StringBuffer.append()方法中都有一个同步块,锁就是sb对象,虚拟机发现变量sb的作用于限定在方法内,也就是说sb的所有
引用永远不会“逃逸”出去,其他现场无法访问到它,虽然里面有锁,也可以被安全消除,在编译后就会忽略同步直接执行。

3、锁粗化

编写代码时,总是推荐同步块的作用范围尽量小--只在共享数据的实际作用域中才进行同步,这样为了使得需要同步的操作数量尽可能变小,
如果存在锁竞争,那等待锁的线程也能尽快拿到锁。
大部分情况下,上面的方法是正确的。但如果一系列的连续操作都对同一个对象反复加锁,针织加锁操作是在循环体的,那即使没有线程竞争,
频繁的互斥同步操作也会导致不必要的性能损耗。
如上述append()方法就属于这类情况,如果虚拟机探测到有这样一串零碎操作都对同一个对象加锁,将会把加锁同步范围扩展(粗化)到
整个操作序列的外部,这样只需加锁一次就可以。

4、轻量级锁

轻量级是相对于使用操作系统互斥量来实现的传统锁而言的,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,
减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

HotSpot虚拟机对象头内存布局,第一部分用于存储对象自身运行时数据,如哈希码(HashCode)、GC分代年龄等,这部分数据长度在32位和64位
虚拟机中分别为32bit和64bit,官方称为“Mark Word”,它是实现轻量级锁和偏向锁的关键。
另外一部分用于存储指向方法区对象类型数据的指针,如果是数组的话,还会有一个额外的部分用于存储数组长度。
Mark Word被设计成一个非固定的数据结构,以便在极小的空间内存储尽量多的信息,他会根据对象的状态复用存储空间。
如32位的HotSpot虚拟机中对象未被锁定的状态下,Mark word 的32bit 空间中的25bit用于存储对象哈希码,4bit存储对象分代年龄,
2bit存储锁标志位,1bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容见下表

存储内容 标志位 状态
对象哈希码、对象分代年龄 01 未状态
执行锁记录的指针 00 轻量级锁定
执行重量级锁的指针 10 膨胀(重量级锁定)
空,不记录信息 11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向

代码进入同步块时,如果此同步的对象没有锁定(锁标志位为01),虚拟机首先将当前线程的栈帧中建立一个名为锁记录的空间,用于存储
锁对象目前的Mark Word的拷贝。
然后虚拟机将使用CAS操作尝试将对象的Mark work 更新为指向Lock Record 的指针。如果更新成功,那么这个线程就拥有该对象的锁,并且
对象mark word的锁标志位转变为“00”,即表示处于轻量级锁定状态。
如果更新操作失败,虚拟机首先会检查对象的Mark word是否指向当前线程的栈帧,如果是说明当前线程拥有了这个对象的锁,那就可以直接
进入同步块继续执行。否则说明这个锁对象已经被其他线程抢占。
如果有两个以上线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志位变为“10”,Mark word 存储的就是指向重量级锁的指针,
后面等待的线程要进入阻塞状态。

解锁过程,如果对象的Mark word 仍然指向线程的锁记录,用CAS操作把对象当前的Mark word和线程中复制的displaced mark word替换回来,
如果成功,整个同步就完成了。如果替换失败,说明有其他线程尝试获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。

5、偏向锁

在无竞争的情况下把整个同步都消除掉。
这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则只有偏向锁的线程将永远不需要在进行同步。

网友评论

登录后评论
0/500
评论
南方之木
+ 关注