并发编程之线程安全性以及内置锁

本章介绍线程的安全性和内置锁,内容皆是笔者学习《Java并发编程实战》或《Java并发编程的艺术》总结摘抄而来,仅作笔记。

当做个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。正确的行为是指某个类的行为与其行为规范一致。

通常,线程安全性的需求并非来源于对线程的直接使用,而是使用像Servlet这样的框架。下面这个Servlet从请求中提取出数值然执行因数分解,将结果封装到响应中。

public class StatelessFactorizer implements Servlet {
    public void service (ServletRequest req,ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        encodeIntoResponse(resp,factors);
    }
}

StatelessFactorizer是无状态的:它既不包含任何域,也不包含任何对其他类中域的引用。由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状态对象是线程安全的。

大多数Servlet是无状态的,从而极大降低了在实现Servlet线程安全性时的复杂性,只有当Servlet在处理请求时需要保存一些信息,线程安全性才会成为一个问题。

原子性

在Servlet中增加一个命中计数器来统计所处理的请求数量,方法是在Servlet中增加一个long类型的域,每处理一个请求就将这个值加1,实现如下所示:

public class UnsafeCountingFactorizer implements Servlet {
    private long count = 0;
    public long getCount(){
        return count;
    }
    public void service (ServletRequest req,ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        count++;
        encodeIntoResponse(resp,factors);
    }
}

在单线程环境中UnsafeCountingFactorizer 能正确运行,但其并非是线程安全的。原因在于count++递增操作时不是原子的,它包含三个独立的操作:读取count的值,将值加1,将计算结果写入count。在没有同步的情况下,线程A和线程B读取count的值为3,接着执行递增操作,两个线程都将计数器的值设为4,也就导致计数器有了差值。

在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常严重的情况,称为竞态条件(Race Condition)。

当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。最常见的竞态条件类型就是“先检查后执行”,即通过一个可能失效的观测结果来决定下一步的动作。使用“先检查后执行”的一种常见情况就是延迟初始化。延迟初始化的目的是将对象的初始化操作推迟到实际使用时才进行,同时要确保只被初始化一次。实际上就是单例模式中的懒汉式。

public class LazyInitRace {
    private LazyInitRace(){};
    private LazyInitRace instance = null;
    public LazyInitRace getInstance(){
        if(instance == null){
            instance = new LazyInitRace();
        }
        return instance;
    }
}

在LazyInitRace中包含了一个竞态条件,它可能会使这个单例模式产生多个LazyInitRace对象。假设线程A和线程B同时执行getInstance()方法,线程A看到instance为空,因此创建了一个新的LazyInitRace对象。此时线程B同样判断instance为空,也创建了一个新的LazyInitRace对象。那么这两次调用就返回了两个LazyInitRace对象,违反了我们创建这个类的初衷。

与大多数并发错误一样,竞态条件并不总是会产生错误,还需要某种不恰当的执行时序。

UnsafeCountingFactorizer和LazyInitRace都包含一组需要以原子方式执行的操作。要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量。从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。

原子操作是指,对于访问同一个状态的所有操作来说,这个操作是一个以原子方式执行的操作。假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要不完全不执行B,那么A和B对彼此来说就是原子的。

假如UnsafeCountingFactorizer中的递增操作是原子的,竞态条件就不会发生。我们将“先检查后执行“和"读取-修改-写入”等操作称为复合操作,即包含了一组必须以原子方式执行的操作以确保线程安全性。

加锁机制

Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。

关键字synchronized可以修饰实例方法、静态方法和代码块。修饰实例方法时锁对象就是调用此实例方法的实例对象,基本格式如下:

public synchronized 返回值 方法名(){}

静态的synchronized方法以Class对象作为锁,可以控制静态成员的并发。其基本格式如下:

public static synchronized 返回值 方法名(){}

如果一个类中既有静态synchronized方法又有非静态synchronized方法,线程A调用静态synchronized方法时,线程B调用非静态synchronized方法是可以的,因为他们的锁不一样。静态synchronized方法的锁是类的Class对象,非静态synchronized方法的锁是调用方法的实例对象。

在只有一小部分语句需要加锁时就可以使用同步代码块。其基本格式如下:

synchronized(锁对象){}

每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时释放锁,无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进去由这个锁保护的同步代码块或方法。

Java的内置锁相当于一种互斥锁,这意味着最多只有一个线程能持有这种锁。当线程A尝试获取一个线程B持有的锁时,线程A必须等待或阻塞,直到线程B释放这个锁。如果B永远不释放锁,那么A将永远等下去。

由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义:一组作为一个不可分割的单元被执行的语句。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。

当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。”重入“意味着获取锁的操作的粒度是“线程”,而不是调用。重入的一种实现方法是为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将计数器的值置为1。如果同一个线程再次获取这个锁,计数值将递增。当线程退出同步代码块时,计数器会对应的递减。当计数值为0时,这个锁将被释放。

Synchronized实现原理

JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步使用monitorenter和monitorexit指令实现的,而方法同步是使用另一种方式实现的。但方法的同步也可以使用这两个指令实现。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit时插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当一个monitor被持有后,它将出于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头;如果对象是非数组类型,则用2个字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit。如下图所示

Java对象头的长度
长度内容说明
32/64bitMark Word存储对象的hashCode、分代年龄、GC标志或锁信息等
32/64bitClass Metadata Address存储到对象类型数据的指针,JVM通过这个指针确定该对象是哪个类的实例
32/32bitArray length如果当前对象是数组,则存储的是数组的长度

32位JVM的Mark Word的默认存储结构如下表。

Java对象头的存储结构
锁状态25bit4bit1bit(是否是偏向锁)2bit(锁标志位)
无锁状态对象的hashCode对象分代年龄001

在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下四种数据,如下表。

Mark Word的状态变化
锁状态25bit4bit1bit2bit
23bit2bit是否是偏向锁锁标志位
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10
GC标记11
偏向锁线程IDEpoch对象分代年龄101

在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下表。

Mark Word的存储结构
锁状态25bit31bit1bit4bit1bit2bit
  cms_free分代年龄偏向锁锁标志位
无锁unusedhashCode  001
偏向锁ThreadId(54bit) Epoch(2bit)  101

CAS

CAS(compare and swap,简称CAS),即比较交换,是在无锁的策略中使用的一个原子算法。一个CAS算法包含以下三个参数:

  • V:要更新的变量
  • E:预期值
  • N:新值

当V的值与E相同时才会将V的值更新为N,否则说明V的值可能被其他线程改了,当前线程放弃此操作。

当多个线程同时使用CAS竞争一个变量时,只会有一个成功。其他失败的线程可以放弃,也可以自旋CAS操作(即循环执行CAS操作,直到成功)。CAS与锁相比,性能更优越,并且由于是非阻塞的,没有死锁问题。

但目前来看CAS操作也是有竞态条件问题的,属于“先比较后执行”类型,那是不是就没有什么意义了呢?不是的。笔者在上面的定义中强调了是一个原子操作,即在CAS中,比较V与E的值后更新V的值其实是原子的。这个原子操作是由CPU的指令实现的,这里不深入介绍了(笔者以后或许深入了解之后再补充)。

虽然CAS很高效的解决了院子操作,但CAS仍然存在三大问题:

  1. ABA问题:如果一个变量原来的值是A,变成了B,又变成了A,使用CAS检查时发现它的值并没有发生变化,但实际上却变化了。解决方式是在变量前面加上版本号,每次变量更新的时候把版本号加1。从JDK1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
  2. 循环时间长开销大:自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
  3. 只能保证一个共享变量的原子操作:当对一个共享变量执行操作时,可以使用循环CAS的方式来保证原子操作,但对多个共享变量操作时,循环CAS就无法保证操作的原子性,这时可以用锁。

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制,包括偏向锁、轻量级锁和互斥锁。除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁;当它退出同步块的时候使用循环CAS释放锁。

锁的优化

Java SE 1.6为了较少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁。锁一共有四种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。这几个状态会随着竞争情况而逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

偏向锁

HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单的测试一下对象头里的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁;如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置为1。如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁使用了一种等到竞争出现才释放锁的机制,即当其他线程尝试竞争偏向锁时,持有偏向锁的线程才释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否还活着。如果线程不处于活动状态,则将对象头设置为无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。下图线程A演示了偏向锁初始化的流程,线程B演示了偏向锁撤销的流程。

偏向锁在Java6和Java7中是默认启用的,但它在应用程序启动几秒钟后才激活,如果有必要可以只用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果应用程序里所有的锁通常情况下出于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进去轻量级锁状态。

轻量级锁

线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Didplaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果替换成功,当前线程获得锁;如果替换失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回对象头。如果替换成功,则表示没有竞争发生;如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

轻量级锁是用于线程交替执行同步块的场景,如果同一时间有多个线程访问同一个同步块,轻量级锁就会膨胀成重量级锁。

下图是两个线程同时争夺锁,导致锁膨胀的流程图。

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁膨胀为重量级锁,就不会再恢复到轻量级锁,当锁出于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

下面是锁的优缺点的对比。

锁的优缺点对比
优点缺点适用场景
偏向锁加锁和解锁都不需要额外的消耗,与执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗只有一个线程访问同步块的场景
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到锁竞争的线程,使用自旋会消耗CPU追求响应时间或同步块执行速度非常块
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量或同步块执行时间较长
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值