15. 线程安全和锁优化

一、Java中的线程安全

按照线程安全的“安全程度”由强至弱来排序,我们可以将Java语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

1.1 不可变

在Java语言里面,不可变对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施。只要一个不可变的对象被正确的构建出来,那其外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态。“ 不可变”带来的安全性是最直接、 最纯粹的。

Java语言中,如果多线程共享的数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的(具体参考 Java Integer的不可变性)。如果共享数据是一个对象,由于Java语言目前暂时还没有提供值类型的支持,那就需要对象自身保证其行为(即方法)不会对其状态产生任何影响才行。如Java里的String对象实例,它是一个典型的不可变对象,用户调用他的substring()、replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。

1.2 绝对线程安全

绝对线程安全的定义很严格,一个类要达到不管运行时环境如何,调用者都不需要任何额外的同步措施“可能需要付出非常高昂的,甚至不切实际的代价。“ 在Java API中标注自己时线程安全的类,大多数都不是绝对的线程安全。

如Vector我们都知道是一个线程安全的类,它的add()、get()、size()方法都是被synchronized修饰,尽管这样效率不高,但保证了具备原子性、可见性和有序性。不过,尽管其所有的方法都被修饰成synchronized,也不意味着调用它的时候就永远都不再需要同步手段了。如下代码

public class VectorTest {
    private static Vector<Integer> vector = new Vector<Integer>();
    public static void main(String[] args) {
        while (true) {
            for (int i = 0; i < 10; i++) {
                vector.add(i);
            }
            Thread removeThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < vector.size(); i++) {
                        vector.remove(i);
                    }
                }
            });
            removeThread.start();
            //不要同时产生过多的线程,否则会导致操作系统假死
            while (Thread.activeCount() > 30);
        }
    }
}
//报错:
/**
Exception in thread "Thread-416" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 8
	at java.util.Vector.remove(Vector.java:836)
	at parallel.VectorTest$1.run(VectorTest.java:22)
	at java.lang.Thread.run(Thread.java:748)
*/

尽管Vector的get()、remove()和size()方法都是同步的,但是在多线程环境,如果一个线程a先获得了i,然后线程被挂起了,另一个线程b获得相同的i,进行了remove,然后a被唤醒在remove i的时候,就发现数组超出范围了。

假如Vector一定要做到绝对的线程安全,那就必须在它内部维护一组一致性的快照访问才行,每次 对其中元素进行改动都要产生新的快照,这样要付出的时间和空间成本都是非常大的。

所以用户要想保证该方法是线程安全,必须手动添加同步措施。

Thread removeThread = new Thread(new Runnable() {
  @Override
  public void run() {
    synchronized (vector) {
      for (int i = 0; i < vector.size(); i++) {
        vector.remove(i);
      }

    }
  }
});

1.3 相对线程安全

相对线程安全就是我们通常意义上所讲的线程安全,它需要保证这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。如上代码就是相对线程安全的案例。

在Java语言中,大部分声称线程安全的类都属于这种类型,例如Vector、HashTable、Collections的 synchronizedCollection()方法包装的集合等。

1.4 线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。我们平常说一个类不是线程安全的,通常就是指这种情况。Java类库API中大部分的类都是线程兼容的,如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等。

二、线程安全的实现方法

2.1 互斥同步

互斥同步是一种最常见也是最主要的并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证数据在同一个时刻只被一条线程使用。

2.1.1synchronized

在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构的同步语法。synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确制定了对象参数,那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。

在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没有被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随机就被释放了。如果获取对象 锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。

**从功能上看,**根据monitorenter和monitorexit的行为描述,我们可以得出两个关于synchronized的直接推论:

  • 被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况。
  • 被synchronized修饰的同步块在持有的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着它无法强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时推出。

**从执行成本的角度看,**持有锁是一个重量级的操作。在上一章我们知道了在主流Java虚拟机中,Java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙,这就不可避免地陷入用户态到核心态的转换中,这种状态转换需要耗费很多时间。对于代码特别简单的同步代码块,状态转换消耗的时间甚至会比用户代码本身执行的时间还要长。因此才说,synchronized是重量级的操作。(在 JDK6 中有优化了)

2.1.2 Lock

上面介绍了synchronized的局限性,除了synchronized关键字以外,自JDK5起,Java类库中新提供了java.util.concurrent包(简称J.U.C包),其中的Lock接口便成了Java的另一种全新的互斥同步手段。基于Lock接口,用户能够以非块结构实现同步,从而摆脱了语言特性的束缚,改为在类库层面去实现同步。

ReentrantLock(重入锁)是Lock接口的最常见的一种实现,它与synchronized一样是可以重入的。ReentrantLock与synchronized相比增加了一些高级功能,主要有以下三项:等待可中断、可实现公平锁及可以绑定多个条件。

  • **等待可中断:**是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助。
  • **公平锁:**是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized和ReentrantLock都是默认非公平的,ReentrantLock可以手动设置成公平锁,但一旦使用了公平锁,性能将急剧下降,明显影响吞吐量。
  • **锁绑定多个条件:**是指一个ReentrantLock对象可以同时绑定多个Condition对象。

如果需要使用上述功能,使用 ReentrantLock 是一个很好的选择,但如果基于性能考虑, synchronized在JDK5以前性能不好,但是JDK6中有专门做针对性的优化(锁优化中讲解),二者性能基本上能够持平。

从长远来看,Java虚拟机更容易针对synchronized来进行优化,因为Java虚拟机可以在线程和对象的元数据中记录synchronized中锁的相关信息。

2.2 非阻塞同步

互斥同步面临的主要问题是进行线程阻塞和唤醒锁带来的性能开销,因此这种同步也被称为阻塞同步。从解决问题的方式上看,互斥同步属于一种悲观的并发策略,无论共享的数据是否真的会出现竞争,它都会进行加锁(这里讨论的是概念模型,实际上虚拟机会有优化),这将会导致用户态到核心态的转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等开销。

随着硬件指令集的发展,我们已经有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施就是不断地重试,直到出现没有竞争的共享数据为止。这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被 称为非阻塞同步(Non-Blocking Synchronization),使用这种措施的代码也常被称为无锁(Lock-Free) 编程。

为什么乐观并发策略需要“硬件指令集发展”呢?**因为我们必须要求操作和冲突检测这 两个步骤具备原子性。所以我们只能靠硬件来实现这件事情,硬件保证某些从语义上看起来需要多次操作的行为可以只通过一 条处理器指令就能完成。**这类指令常用的有:Test-and-Set、Fetch-and-Increment、Swap、Compare-and-Swap(CAS)、Load-Linked/Store-Conditional,Java暴露出来的是CAS操作。

CAS指令需要三个操作数,分别是变量的内存地址(V)、旧的预期值(A) 和准备设置的新值(B)。**CAS指令执行的时候,当且仅当V符合A时,处理器才会用B更新V的值,负责它就不执行更新。**但是,不管是否更新了V的值,都会返回V的旧值,上述过程是一个原子操作,执行期间不会被其他线程中断。

java中的源码:

/**
var1: 对象本身
var2: 对象的地址
var4: 要增加的值
*/
public final int getAndAddInt(Object var1, long var2, int var4) {
  int var5;
  do {
    var5 = this.getIntVolatile(var1, var2); //必须是用volatile修饰的变量,否则会导致上一章描述的工作内存和主存不一致的情况
  } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); 
  //判断var5的值和var2所在地址的值是否一样,如果一样,用var5+var4赋值给var1

  return var5;
}

CAS的缺点:

  • 只能保证一个共享变量的原子操作;
  • 长时间自旋可能导致的CPU开销大;
  • ABA问题。CAS的核心思想是通过比对内存值与预期值是否一样而判断内存值是否被改过,但这个判断逻辑不严谨,假如内存值原来是A,后来被一条线程改为B,最后又被改成了A,则CAS认为此内存值并没有发生改变,但实际上是有被其他线程改过的,这种情况对依赖过程值的情景的运算结果影响很大。解决的思路是引入版本号,每次变量更新都把版本号加一。

三、锁优化

3.1 自旋锁和自适应锁

3.1.1 自旋锁

**含义:**前面提到互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给Java虚拟机的并发性能带来了很大的压力。Java虚拟机团队注意到在许多应用上,共享数据的锁定时间只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。所以可以让后面请求锁的那个线程“稍等一会儿”,但不放弃处理器的执行时间。为了让线程等待,我们只须让线程执行一个死循环(自旋),这项技术就是所谓的自旋锁。

自旋锁在JDK1.4中就已经引入,但默认是关闭的,在JDK6中改为默认开启。

**缺点:**如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理 器资源,而不会做任何有价值的工作,这就会带来性能的浪费。

3.1.2 自适应锁

为了减小自旋锁缺点的影响,在JDK6中引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自 旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。

3.2 锁消除

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

3.3 锁粗化

原则上,我们在编写代码的时候,总是推荐奖同步块的作用范围限制得尽量小——只在共享数据的实际作用域才进行同步,这样是为了使得需要同步的操作尽可能变少,即使存在锁竞争,等待锁的线程也能尽快地拿到锁。

大多数情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

如下代码,如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展到整个操作序列的外部,就是宽栈道第一个appedn()操作之前直至最后一个append()操作之后。

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(); 
}

3.4 轻量级锁

轻量级锁是JDK 6时加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现传统锁而言的,因此传统的锁机制就被称为“重量级”锁。但轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统重量级锁使用操作系统互斥量产生的性能消耗。(我理解是不用如果使用了轻量级锁,就不用引入内核态和用户态转换的消耗了)

轻量级锁是利用对象头实现的,由于对象头信息是与对象自身定义的数据无关的额外存储,为了提高空间利用率,Mark Word 被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。它会根据对象的状态复用自己的存储空间。下图展示了各种状态下的Mark Word存储内容

在这里插入图片描述

工作工程:

  1. 如果此同步对象没有被锁定(锁标识位为 “01” 状态),虚拟机首先将在当前线程的栈中开辟一个名为 Lock Record 的空间,用于存储对象目前的 Mark Word的拷贝(用于在后续解锁时写回对象,官方命名为 Displaced Mark Word)。此时的线堆栈与对象头的状态如下图所示

在这里插入图片描述

i. 虚拟机将使用 CAS 操作尝试把这个对象的 Mark Word 更新为指向 Lock Record 的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位将转变成“00”,表示此对象处于轻量级锁定状态。此时线程堆栈与对象头的状态如下图所示

在这里插入图片描述

​ ii. 如果这个操作更新失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟 机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志 的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。

​ 这段描述有个疑问,为什么在2个及以下的线程竞争时,用的是轻量级,在2个以上竞争时,用的是重量级?

​ 我暂时理解是有2个点:

​ a. 轻量级锁设计初衷就是在没有多线程竞争的前提下,减少避免内核态和用户态切换的损耗;

​ b. 如果2个及以下竞争时,只会出现一个线程自旋,性能损耗还可以接受,但是如果有 n(n >2) 个竞争,就会出现 (n - 1)个线程自旋,白白浪费cpu资源。

​ iii. 解锁过程同样也是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的 Lock Record,那就用CAS操作把对象当前的Mark Word和线程中复制的 Displaced Mark Word 替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有 其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。

**轻量级锁能提升程序同步性能的依据是“ 对于绝大部分的锁,在整个同步周期内都是不存在竞争 的”这一经验法则。**如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销; 但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下, 轻量级锁反而会比传统的重量级锁更慢。

3.5 偏向锁

偏向锁也是JDK6中引入的一项优化措施,它的目的是消除在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了

偏向锁中的“偏”,就是偏心的“偏”、偏袒的“偏”。它的意思是这个锁会偏向于第一个获取它的线程,如果在接下来的执行过程中,该锁一直都没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。

工作机制:

  1. 当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标识位设置成“01”,把偏向模式设置成“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(如加锁、解锁及对Mark Word的更新操作等)

  2. 一旦另外一个线程去尝试获取这个锁的情况,偏向模式九马上宣告结束。根据锁对象目前是否处于被锁定状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态。后续的同步操作就按照轻量级锁去执行。

在这里插入图片描述

  1. 上述流程存在一个问题:当对象进入偏向状态时,Mark Word大部分的空间都用来存储线程ID了,这部分空间占用了原有存储对象哈希码的位置,那原来的对象哈希码该怎么办呢?

    在Java语言里面一个对象如果计算过哈希码,就应该一直保持值不变,否则很多依赖对象哈希码的API都可能存在出错风险。因此,当一个对象已经锁计算一次一致性哈希之后,它就再也无法进入到偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。

    为什么不是膨胀为轻量级锁呢?

    因为重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态下的Mark Word,其中自然可以存储原来的哈希码。

    而如果使用轻量级锁,还需要在线程栈中建立 Lock Record空间,并尝试用CAS让该线程获取锁,之后获取到锁之后才能复制Mark Word;而计算一致性Hash还可能出现在对象处于已锁定的状态中,用轻量锁肯定不满足。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值