个人成长博客

纸上得来终觉浅,绝知此事要躬行

0%

线程安全与锁优化

概述

线程安全是多线程编程时的计算机程序代码中的一个概念。在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。

Java语言中的线程安全

我们这里讨论的线程安全,限定于多个线程之间存在共享数据访问这个前提,因为如果一段代码根本不会与其他线程共享数据,那么从线程安全的角度来看,程序是串行执行还是多线程执行对它来说是完全没有区别的。按照线程安全的 “安全程度” 由强至弱来排序,可以将 Java 语言中各个操作共享的数据分为以下 5 类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

  1. 不可变:在Java语言中不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要采取任何的线程安全保障措施。如果共享数据是一个基本数据类型,那么只要在定义时使用 final 关键字修饰它就可以保证它是不可变的;在 Java API 中符合不可变要求的类型,除了String之外,常用的还有枚举类型,以及 java.lang.Number 的部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。
  2. 绝对线程安全:一个类要达到 “不管运行是环境如何,调用者都不需要任何额外的同步措施” 通常需要付出很大的,甚至有时候是不切实际的代价。这种类称之为绝对线程安全。例如java.util.Vector是一个线程安全的容器,它的 add()、get() 和 size() 这类方法都是被 synchronized 修饰的,所以任何情况下都是同步操作。
  3. 相对线程安全:相对的线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。在 Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。
  4. 线程兼容: 线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。Java API 中大部分的类都是属于线程兼容的,如与前面的 Vector 和 HashTable 相对应的集合类 ArrayList 和 HashMap 等。
  5. 线程对立:线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。常见的线程对立的操作还有 System.setIn()、System.setOut() 和 System.runFinalizerosOnExit() 等。

线程安全的实现方法

了解了什么是线程安全之后,紧接着的一个问题就是我们应该如何实现线程安全,这听起来似乎是一件由代码如何编写来决定的事情,确实,如何实现线程安全与代码编写有很大的关系,但虚拟机提供的同步和锁机制也起到了非常重要的作用。

互斥同步

互斥同步(Mutual Exclusion & Synchronization)是常见的一种并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。因此,在这 4 个字里面,互斥是因,同步是果;互斥是方法,同步是目的。

synchronized关键字

synchronized简介

synchronized是互斥锁,互斥锁具备两个特性:

  1. 互斥性:即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程的协调机制,这样在同一时间只有一个线程对所需要同步的代码块(复合操作)进行访问。互斥性也称为操作的原子性。
  2. 可见性:必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某一个副本上继续操作,从而引起不一致。

synchronized锁的不是代码,锁的都是对象。根据获取的锁分类分为两类:

  1. 对象锁:
    • 同步代码块(synchronized(this),synchronized(类实例对象)),锁是小括号中的对象
    • 同步非静态方法(synchronized method),锁是当前对象的实例对象
  2. 类锁:
    • 同步代码块(synchronized(类.class)),锁是小括号中类对象(Class对象)
    • 同步静态方法(synchronized static method)锁是当前对象的类对象(Class对象)

对象锁和类锁注意点:

  1. 有线程访问对象的同步代码块时,另外的线程可以访问同步代码块之前的该对象的非同步代码块
  2. 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象同步代码块的线程会被阻塞
  3. 若锁住的是同一个对象,一个线程在访问对象的同步方法时,另一个访问对象同步方法的线程会被阻塞
  4. 若锁住的是同一个对象,一个线程在访问对象的同步方法时,另一个访问对象同步代码块的线程会被阻塞,反之亦然
  5. 同一个类的不同对象的对象锁互不干扰
  6. 类锁由于也是一种特殊的对象锁,因此表现和上述1,2,3,4一致,而由于一个类只有一把对象锁,所以同一个类的不同对象使用类锁,将会是同步的
  7. 类锁和对象锁互不干扰

synchronized底层实现原理

Java对象头和Monitor是实现synchronized的基础。

Java对象头的结构为:

虚拟机位数 头对象结构 说明
32/64 bit Mark Word 默认存储对象的hashcode,分代年龄,锁类型,锁标志位等信息
32/64 bit Class Metadata Address 元数据指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的数据

其中Mark Word是该对象关键的运行时数据,被设计成一个非固定的数据结构以便在极小的空间中存储尽量多的信息,它会根据对象的状态复用自己的存储空间,详细情况如下图:

Monitor:每个Java对象天生自带了一把看不见的锁,叫做内部锁或者Monitor锁。Monitor由在JVM中ObjectMonitor实现的,C++编写,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ObjectMonitor() {
_header = NULL;
_count = 0;//锁的计数器
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;//指向持有objectmonitor的线程
_WaitSet = NULL;//等待池
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ;//锁池
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}

当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1。即获得对象锁。若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示:

需要注意的是,synchronized具有可重入性,所谓可重入性是指:当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,并不会阻塞,这种情况属于可重入。

锁优化

事实上,只有在JDK1.6之前,synchronized的实现才会直接调用ObjectMonitor的enter和exit,这种锁被称之为重量级锁。这种依赖于Mutex Lock实现,线程之间切换需要从用户态转换到核心态,开销较大。所以,在JDK1.6中对锁进行了很多的优化,进而出现轻量级锁,偏向锁,锁消除,适应性自旋锁,锁粗化(自旋锁在1.4就有 只不过默认的是关闭的,jdk1.6是默认开启的),这些操作都是为了在线程之间更高效的共享数据,解决竞争问题。

  1. 自旋锁:许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得。通过让线程执行忙循环等待锁释放,不让出CPU。自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费
  2. 自适应的自旋锁:自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理部资源
  3. 锁消除:JTI编译时,对运行的上下文进行扫描,去除不可能存在竞争的锁
  4. 锁粗化:通过扩大加锁的范围,避免反复加锁、解锁
  5. 偏向锁:偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,只需要检查Mark Word的锁标记位为偏向锁以及当前线程Id等于Mark Word的Thread Id即可,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。不适合锁竞争比较激烈的多线程场景。
  6. 轻量级锁:轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级轻量级锁。适用场景,线程交替执行同步块。
  7. 重量级锁:传统的锁机制就称为 “重量级” 锁。传统的锁使用操作系统互斥量,消耗资源比较大

锁膨胀方向为无锁、偏向锁、轻量级锁、重量级锁。

优点 缺点 使用场景
偏向锁 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 只有一个线程访问同步块或者同步方法的场景
轻量级锁 竞争的线程不会阻塞,提高了响应速度 若线程长时间抢不到锁,自旋会消耗CPU性能 线程交替执行同步块或者同步方法的场景
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 追求吞吐量,同步块或者同步方法执行时间较长的场景

ReentrantLock

java除了使用关键字synchronized外,还可以使用ReentrantLock实现独占锁的功能。而且ReentrantLock相比synchronized而言功能更加丰富,使用起来更为灵活,也更适合复杂的并发场景。从代码写法上两者有点区别,一个表现为API层面的互斥锁(lock()和unlock()方法配合try/finally语句完成),另一个表现为原生语法层面的互斥锁。不过,相比synchronized,ReentrantLock增加了一些高级功能,主要有以下3项:等待可中断、可实现公平锁、以及锁可以绑定多个条件。

  1. 等待可中断:是指当持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助
  2. 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁
  3. 锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无须这样做,只需要多次调用newCondition()方法即可

非阻塞同步

阻塞同步(互斥同步):主要问题是进行线程阻塞和唤醒所带来的性能问题,互斥同步属于一种悲观的并发策略,无论共享数据是否真的会出现竞争,它都要进行加锁,用户态核心态转换,维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

非阻塞同步定义:基于冲突检测的乐观并发策略,通俗的说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采用其他的补偿措施,这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为 非阻塞同步。

CAS(Compare And Swap)应运而生,CAS包含三个操作数,内存位置(V)、预期原值(A)和新值(B),主要思想是执行命令时会将V和A比较相同则更新为B,否则不做任何操作。CAS是一种高效实现线程安全性的方法。支持原子更新操作,适用于计数器,序列发生等场景。属于乐观锁机制,号称lock-free。CAS操作失败时由开发者决定是继续尝试,还是执行别的操作。CAS也有一些缺点:若循环时间长,则开销很大,只能保证一个共享变量的原子操作,还有一个比较经典的ABA问题,J.U.C 包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的version 来保证CAS的正确性。

无同步方案

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

可重入代码

也叫作纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。所有的可重入代码都是线程安全的;如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

线程本地存储

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能够保证在同一线程中执行? 如果能保证,我们就可以把共享数据的可见范围限制在同一个线程内,这样,无需同步也可以保证线程间不出现数据争用问题。