线程安全和锁

1、进程与线程的概念

先来复习一下操作系统中所讲的进程、线程这两个概念:

  • 进程:是并发执行的程序在执行过程中分配和管理资源的基本单位,是一个动态概念,竞争计算机系统资源的基本单位。
  • 线程:是进程的一个执行单元,是进程内可调度实体。比进程更小的独立运行的基本单位。线程也被称为轻量级进程。

2、并发与并行的概念

  • 并发:指的是某个时间段内,多任务交替处理的能力。在某段执行时间内,每个CPU不可能只顾着执行某个进程,而让其他进程一致处于等待状态,所以CPU把可执行的时间均匀地分成若干份,每个进程执行一段时间后,记录当前的工作状态,释放相关资源并进入等待状态,让其他进程来抢占CPU资源。
  • 并行:指的是同时处理多任务的能力。目前的CPU已经发展到多核,可以同时执行多个互不依赖的指令及执行块。

并发与并行的目的都是尽可能快地执行完所有任务。以医生坐诊为例,某个科室有两个专家同时出诊,这就是两个并行任务;其中一个医生,时而问诊,时而查看化验单,然后又继续问诊,突然又中断去处理病人的咨询,这就是并发。

在并发的环境下,由于程序的封闭性被打破,出现了以下的特点:

  1. 并发程序之间有相互制约的关系。直接制约体现为一个程序需要另一个程序的计算结果;间接制约则体现为多个程序竞争共享资源,如处理器、缓冲区等。
  2. 并发程序的执行过程是断断续续的。程序需要记忆现场指令及执行点。
  3. 当并发数设置合理并且CPU拥有足够的处理能力时,并发会提高程序的运行效率。

3、线程安全

线程是CPU调度和分派的基本单位,为了更充分地利用CPU资源,一般会使用多线程进行处理。多线程的作用是提高任务的平均执行速度,但是会导致程序可理解性变差,编程难度较大。

例如,楼下有一车砖头需要工人搬到6楼,如果10个人一起搬,速度肯定是要比一个人搬要快,完成任务的时间会大大降低。但是论单次的时间成本,相比一个人去搬,10个人势必会造成楼道内更加拥挤堵塞,所以10个人一起上下楼梯的速度肯定要比1个人慢。如果无限制地增加人数,比如10000个人参与搬砖,反而会因为楼道拥堵不堪导致变得更慢,所以合适的人数才会使得工作效率最大化。同理,合适的线程数才能让CPU资源被充分利用,线程数量并不是说越多越好

下图为计算机的资源监视数据,PID指的就是进程ID,平均CPU指的是进程所使用CPU在60秒内的平均百分比。

线程可以拥有自己的操作栈、程序计数器、局部变量表等资源,它与统一进程内的其他线程共享该进程的所有资源。线程在生命周期内存在多种状态,有NEW(新建状态)、RUNNABLE(就绪状态)、RUNNING(运行状态)、BLOCKED(阻塞状态)、DEAD(中止状态)这五种状态。

  • NEW,即新建状态,是线程被创建且未启动的状态。创建线程的方式有三种:第一种是继承自Thread类,第二种是实现Runnable接口,第三种是实现Callable接口。相比第一种,推荐第二种方式,因为继承自Thread类往往不符合里式代换原则(即父类适用的地方子类同样适用),而实现Runnable接口可以使编程更加灵活,对外暴露的细节比较少,让使用者专注于实现线程的run()方法上。第三种Callable接口的call()声明如下:
    /**
     * Computes a result, or throws an exception if unable to do so.
     * @return computed result, V is generics value
     * @throows Exception if unable to compute a result
     */
     V call() throws Exception;
     /**
     * 由此可知,Callable与Runnable有两点不同:
     * 第一,可以通过call()获得返回值。前两种方式都有一个共同的缺陷,即在任务执行完成后,无法直接获取执行结果,需要借助
     * 共享变量等获取,而Callable和Future则很好地解决了这个问题。
     * 第二,call()可以抛出异常。而Runnable只能通过setDefaultUncaughtExceptionHandler()的方式才能在主线程中捕捉到子
     * 线程异常。
     */

     

  • RUNNABLE,即就绪状态,是调用start()之后运行之前的状态。线程的start()不能被多次调用,否则会抛出IllegaStateException异常。

  • RUNNING,即运行状态,是run()正在执行时线程的状态。线程可能会由于某些因素而退出RUNNING,如时间、异常、锁、调度等。

  • BLOCKED,即阻塞状态,进入此状态,有以下三种情况:同步阻塞(锁被其他线程占用)、主动阻塞(调用Thread的某些方法,主动让出CPU执行权,比如sleep()、join()等)、等待阻塞(执行了wait())。

  • DEAD,即中止状态,是run()方法执行结束,或因异常退出后的状态,此状态不可逆转

计算机线程处理过程当中,因为各个线程轮流占用CPU的计算资源,可能会出现某个线程还没执行完就不得不中断的情况,容易导致线程不安全。例如,在服务端某个高并发业务共享某用户数据,首先A线程执行用户数据查询任务,但数据尚未返回就退出CPU时间片;然后B线程抢占了CPU资源执行并覆盖了该用户数据,最后A返回中断现场,直接将B线程处理后的用户数据返回给前端,导致页面显示数据错误。为保证线程安全,在多个线程并发地竞争共享资源时,通常采用同步机制协调各个线程的执行,以确保得到正确的结果

想要保证并发场景下的线程安全,可以从以下四个维度考量:

  1. 数据单线程内可见。单线程总是安全的,通过限制数据仅在单线程内可见,可以避免数据被其他线程篡改。最典型的就是线程局部变量,它存储在独立虚拟机栈帧的局部变量表中,与其他线程毫无瓜葛。ThreadLocal就是采用这种方式来实现线程安全的。
  2. 只读对象。只读对象总是安全的,它的特性是允许复制、拒绝写入。最典型的只读对象由String、Integer等。一个对象想要拒绝任何写入,就必须满足一下四个条件:使用final修饰类,避免被继承;使用private final关键字来避免属性被中途修改;没有任何更新方法;返回值不能为可变对象。
  3. 线程安全类。某些线程安全类的内部有非常明确的线程安全机制。比如StringBuffer就是一个线程安全类,它采用synchronized关键字来修饰相关方法。
  4. 同步与锁机制。如果想对某个对象进行并发更新操作,但又不属于上述三类,就需要开发工程师在代码中实现安全的同步机制。虽然这个机制支持的并发场景很有价值,但非常复杂且容易出现问题。

线程安全的核心理念就是“要么只读,要么加锁”。要学会合理运用Java并发包(java.util.concurrent,即JUC)。JUC主要分成以下几个类族:

  1. 线程同步类。这些类使线程间的协调更加容易,支持了更加丰富的线程协调场景,逐步淘汰了使用Object的wait()和notify()进行同步的方式。主要代表为CountDownLatch、Semaphore、CyclicBarrier等。
  2. 并发集合类。集合并发操作的要求是执行速度快,提取数据准,最著名的类非ConcurrentHashMap莫属,它不断地优化,由刚开始的锁分段到后来的CAS,不断地提升并发性能,其他还有ConcurrentSkipListMap、CopyOnWriteArrayList、BlockingQueue等。
  3. 线程管理类。虽然Thread和ThreadLocal在JDK1.0中就已经引入,但是真正把Thread发扬光大的是线程池。根据实际场景的需要,提供了多种创建线程池的快捷方式,如使用Executors静态工厂或者使用ThreadPoolExecutor等。另外通过ScheduledExecutorService来执行定时认识。
  4. 锁相关类。锁以Lock接口为核心,派生出在一些实际场景中进行互斥操作的锁相关类。最著名的是ReentrantLock。锁的很多概念在弱化,是因为锁的实现在各种场景中已经通过类库封装进去。

4、什么是锁

在计算机信息世界里,单线程时代没有锁的概念,但自从出现了资源竞争,人们才意识到需要对部分场景的执行现场加锁,以此来昭告天下,表明自己的“短暂”拥有(其实对于任何有形或无形的东西,拥有都不可能是永恒的)。计算机的锁也是从开始的悲观锁,发展到后来的乐观锁、偏向锁、分段锁等。锁主要提供了两种特性:互斥性和不可见性。因为锁的存在,某些操作对外界来说是黑箱(即外界不清楚具体细节)进行的,只有锁的持有者才知道对变量进行了什么修改。


什么是悲观锁?什么是乐观锁?

  • 悲观锁:总是假设最坏的情况,每次取数据时都认为其他线程会修改,所以都加锁(读锁、写锁、行锁等),当其他线程想要访问数据时,都需要阻塞挂起。可以依靠数据库实现,如行锁、读锁、写锁等,都是在操作之前加锁。在Java中,synchrsynchronized的思想也是悲观锁。
  • 乐观锁:总是认为不会产生并发问题,每次去取数据的时候总认为不会有其他线程对数据进行修改,因此不会上锁,但是在更新时会判断其他线程在这之前有没有对数据进行修改,一般会使用版本号机制或者CAS操作实现。(version方式:一般在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值加1。当线程A要更新数据时,在读取数据的同时也会读取version值,在提交更新时,若刚才读到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功)

参考链接:乐观锁与悲观锁


下面通过对JUC包中基础类的解析来说明锁的本质和特性,Java中常用锁实现的方式有两种。

1、用并发包中的锁类

并发包的类族中,Lock是JUC包的顶层接口,它的实现逻辑并未用到synchronized,而是利用了volatile的可见性(因为线程的共享对象存在于堆中,各个线程都是在自己私有的内存区域中创造一个该共享对象的副本进行操作,操作完以后再同步到堆中的本体中去,这中间势必会造成一个时间差,在这个时间差内,各个线程的操作是其他线程不可见的。被volatile修饰的数据不会被创建副本,任何操作都是在堆上对本体直接进行的。这就是volatile的可见性,详细内容的请见我的另一篇博客线程同步)。先通过Lock来了解JUC包的一些基础类,下图为Lock的继承类图:

ReentrantLock对于Lock接口的实现主要是依赖了Sync,而Sync继承了AbstractQueuedSynchronizer(AQS),它是JUC包实现同步的基础工具。在AQS中,定义了一个volatile int state变量作为共享资源,如果线程获取资源失败,则进入同步FIFO队列中等待;如果成功获取资源就执行临界区代码。执行完释放资源时,会通知同步队列中的等待线程来获取资源后出队并执行。

AQS是抽象类,内置自旋锁实现的同步队列,封装入队和出队的操作,提供独占、共享、中断等特性的方法。AQS的子类可以定义不同的资源实现不同性质的方法。比如可重入锁ReentrantLock,定义state为0时可以获取资源并置为1。若已经获得资源,state不断加1,在释放资源时state减1,直至为0;CountDownLatch初始时定义了资源总量state=count,countDown()不断将state减1,当state=0时才能获得锁,释放后state就一直为0。所有线程调用await()都不会等待,所以CountDownLatch是一次性的,用完后如果再想用就只能重新创建一个;如果希望循环使用,推荐使用基于ReentrantLock实现的CyclicBarrier。Semaphore与CountDownLatch略有不同,同样也是定义了资源总量state=permits,当state>0时就能获得锁,并将state减1,当state=0时只能等待其他线程释放锁,当释放锁时state加1,当其他线程又能获得这个锁。当Semphore的permits定义为1时,就是互斥锁,当permits>1就是共享锁。

JDK9提出了一个新的锁:StampedLock,改进了读写锁ReentrantReadWriteLock。这些新增的锁相关类不断丰富了JUC包的内容,降低了并发编程的难度,提高了锁的性能和安全性。

2、利用同步代码块

同步代码块一般使用Java的synchronized关键字来实现(synchronized可以保证可见性和有序性,volatile只能保证可见性,而不能保证有序性),有两种方式对方法进行加锁操作:

  1. 在方法签名处加synchronized关键字;
  2. 使用synchronized(对象或类)进行同步。

这里的原则是锁的范围尽可能小,锁的时间尽可能短,即能锁对象,就不要锁类;能锁代码块,不就要锁方法

synchronized锁特性由JVM负责实现。在JDK的不断优化迭代中,synchronized锁的性能得到极大提升,特别是偏向锁的实现,使得synchronized已经不再是昔日那个低性能且笨重的锁了。JVM底层是通过监视锁来实现synchronized同步的。监视锁即monitor,是每个对象与生俱来的一个隐藏字段。使用synchronized时,JVM会根据synchronized的当前使用环境,找到对应对象的monitor,再根据monitor的状态进行加、解锁的判断。例如,线程在进入同步方法或代码块时,会获取该方法或代码块所属对象的monitor,进行加锁判断。如果成功加锁就成为该monitor的唯一持有者。monitor在被释放前,不能再被其他线程获取。

JVM利用CAS在对象头上设置线程ID,表示这个对象偏向于当前线程,这就是偏向锁。偏向锁是为了在资源没有被多线程竞争的情况下尽量减少锁带来的性能开销。在锁对象的对象头中有一个ThreadId字段,当第一个线程访问锁时,如果该锁没有被其他线程访问过,即ThreadId字段为空,那么JVM让其持有偏向所,并将ThreadId字段的值设置为该线程的ID。当下一次获取锁时,会判断当前线程的ID是否与锁对象的ThreadId一致。如果一致,那么该线程不会再重复获取锁,从而提高了程序的运行效率。如果出现锁的竞争情况,那么偏向锁会被撤销并升级为轻量级锁。如果资源的竞争非常激烈,会升级为重量级锁。偏向锁可以降低无竞争开销,它不是互斥锁,不存在线程竞争的情况,省去在此同步判断的步骤,提升了性能。

5、synchronized的底层实现原理

synchronized修饰同步代码块的原理

示例代码


public class SynchronizedDemo {
	public void method() {
		synchronized (this) {
			System.out.println("synchronized 代码块");
		}
	}
}

synchronized修饰同步代码块时,主要用到两个指令:

  1. monitorenter:指向同步代码块开始的地方;
  2. monitorexit:指向同步代码块结束的地方;

每个对象的对象头中都有一个monitor,即监视器锁。当开始执行monitorenter指令的时候,线程就获取了当前对象的monitor,此时计数器加1(原本计数器值为0,表示该锁可以获取);当执行monitorexit指令的时候,释放monitor,计数器减1。

其他线程向使用该同步代码块的话,要先检查对象头中的monitor状态,如果计数器值为1,则进入阻塞状态,直到当前线程释放锁。

synchronized修饰方法的实现原理

示例代码:

public class SynchronizedDemo2 {
	public synchronized void method() {
		System.out.println("synchronized 方法");
	}
}

synchronized修饰方法时,不再是使用monitorenter和monitorexit指令来实现,而是使用ACC_SYNCHRONIZED标识,这个标识表明了这个方法是一个同步方法。JVM通过检查方法是否有ACC_SYNCHRONIZED标识来判断方法是不是同步方法,从而执行相应的同步调用。

其实原理跟synchronized修饰同步代码块的原理是一样的,都是获取对象的monitor来达到同步,只是没有使用字节码指令而已。

6.锁的四种状态

从JDK1.6开始,对锁的实现进行了极大程度的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁的开销。

锁主要存在四种状态:

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

它们会随着竞争的激烈而逐渐升级,注意!锁只能升级而不能降级!这个策略是为了提高获得锁和释放锁的效率!

需知知识点:对象头中的Mark Word

HotSpot虚拟机的对象头(Object Header)分为两部分,第一部分用于存储对象自身的运行时数据,如哈希码(hashCode)、GC分代年龄等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为Mark Word,它是实现轻量级锁和偏向锁的关键;另一部分用于存储指向方法区对象类型数据的指针;如果是数组对象的话,还会有一个额外的部分用于存储数组长度。

在32bit的HotSpot中,Mark Word的存储内容如下表所示:

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

 

一、偏向锁

引入偏向锁的目的和引入轻量级锁的目的很相似,都是为了在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。但是不同的是:轻量级锁在无竞争的情况下会使用CAS操作去替代使用互斥量;而偏向锁会在无竞争情况下把整个同步操作消除,连CAS操作都不做。

偏向锁中的“偏”字就是偏心的“偏”,意思是它会偏向于第一个获取它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取的话,那么持有偏向锁的线程就不需要进行同步;

如果虚拟机启用了偏向锁,那么当线程第一次获取这个锁对象的时候,虚拟机会把对象头Mark Word中的标志位设为01,即偏向模式。同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步代码块时,都不需要再进行任何同步操作。

但是偏向锁不适用于竞争激烈的场合,因为当有另一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定(标志位为01)或轻量级锁定(标志位为00)的状态。

偏向锁可以提高带有同步但无竞争的程序性能,它同样是一个带有效益权衡性质的优化,也就是说,它不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同线程访问,那偏向锁就是多余的。在具体问题具体分析的情况下,有时候使用参数-XX:-UseBiasedLocking来禁止偏向锁优化反而可以提升性能。

二、轻量级锁

“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统锁的锁机制就成为“重量级”锁。首先要强调的是轻量级锁不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量而产生的性能消耗

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

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

三、自旋锁和自适应自旋

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力(用户态转换到内核态会消耗时间)。

一般线程持有锁的时间不会太长,所以仅仅为了这一点点时间去挂起/恢复线程是很得不偿失的,所以虚拟机开发团队就考虑:不让线程挂起,而是让他等待一段时间,看看持有锁的线程会不很快地释放锁。为了让一个等待锁的线程不被挂起,我们只需要让线程执行一个忙循环(自旋),这项技术就叫自旋。

自旋锁在JDK1.6以后,就被默认为开启了,但是要注意:自旋锁不能完全替代阻塞,因为自旋也会占用处理器时间,如果锁被占用的时间很短,那么效果自然就是最好的,反之!自旋等待的时间必须要有限度,如果自旋超过了限定的次数而依然没有获得锁,那就应该挂起线程。线程自旋的次数默认为10次,用户可以通过--XX:PreBlockSpin来更改。

另外从JDK1.6开始,引入了自适应的自旋锁。自适应的自旋锁带来的改进就是:自旋的时间不再固定了,而是由前一次同一个锁上的自旋时间以及锁的拥有者的状态来决定。比如说在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环;相反,如果对于某个锁,自旋很少成功获得过,那么以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

四、锁消除

虚拟机即使编译器在运行时,如果检测到那些共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省那些毫无意义的请求锁的时间。

五、锁粗化

原则上,我们在编写代码时,总是推荐将同步块的作用范围尽量缩小,能锁对象就不锁类;能锁方法就不锁对象;能锁代码块就不锁方法。争取只在共享数据的实际作用域才进行同步,这样是为了使得需要同步的操作数量尽量变的最小,如果存在锁竞争,那等待线程也能尽快拿到锁。

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

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();
}
/**
 *每个StringBuffer.append()方法中都有一个同步块,锁就是sb对象
 *虚拟机会观察对象sb,发现它的所有引用永远不会“逃逸”到concatString()方法之外,
 *其他外部线程都无法访问到它,因此虽然append()方法中有锁,但是虚拟机会安全的消除它或者进行锁粗化
**/

上面示例代码就属于这类情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步范围扩展(粗化)到整个操作序列的外部,上面的代码中就是扩展到第一个append()操作之前直至最后一个append()操作之后,这样就只需加锁一次就行了。

  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值