Java语言中的线程安全


线程安全的实现方式有三种,一种是互斥同步,一种是非阻塞同步,还有一种是无同步方案。

1 互斥同步(悲观锁)

互斥同步(Mutual Exclusion&Synchronization)是常见的一种并发正确性保障手段,同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。简单来说互斥同步就是以互斥的手段达到顺序访问的目的。
为什么说互斥同步是一种悲观锁呢,因为它总是认为只要不加锁实现正确的同步,就肯定会出现问题,无论共享数据是否真的存在竞争都要去加锁。
下面我们来探究一下java中互斥同步锁的实现。

1.1 synchronized

前面我们在线程的生命周期中也提到了synchronized关键字的加锁使用,这里我们详细介绍一下,主要参考《深入理解JVM》一书。

Synchronized主要是通过对象锁的方式来实现线程安全。它在经过编译之后,会在同步块的前后分别形成monitorentermonitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的 synchronized明确指定了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。比如修饰静态方法获取的是Class对象的锁,修饰方法是获取当前对象this的锁,修饰括号内容是获取括号内对象的锁。

synchronized是Java语言中一个重量级 (Heavyweight)的操作。在JDK1.6之前,有经验的程序员都会在确实必要的情况下才使用这种操作。而虚拟机本身也会进行一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态之中。这是由于Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,但是状态转换需要耗费很多的处理器时间, 对于代码简单的同步块(如被synchronized修饰的getter()或setter()方法),状态转换消耗的时间有可能比用户代码执行的时间还要长。
但是在JDK1.6之后,加入了很多针对锁的优化措施,使得在JDK6之后synchronized性能优化大幅提升,即使在竞争激烈的情况下也能保持一个和ReentrantLock相差不多的性能,所以JDK6之后的程序选择不应该再因为性能问题而放弃synchorized。所以还是提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。

1.2 ReentrantLock

ReentrantLock是java.util.concurrent(J.U.C)包中的重入锁 来实现同步。在基本用法上,ReentrantLock与synchronized很相似,他们都具备一样的线程重入特性,只是代码写法上有点区别,ReentrantLock表现为API层面的互斥锁 (lock()和unlock()方法配合try/finally语句块来完成),synchronized表现为原生语法层面的互斥锁。但是如果程序员忘记释放lock,那么不仅不会提升性能反而会带来额外的问题。另外synchorized是JVM实现的,可以通过监控工具来监控锁的状态,遇到异常JVM会自动释放掉锁。而ReentrantLock必须由程序主动的释放锁。

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

2 非阻塞同步(乐观锁)

(1)非阻塞同步锁
该策略是基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成 功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施 就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起, 因此这种同步操作称为非阻塞同步。
(2)原子性
这种策略需要操作和冲突检测的步骤具备原子性,而保证原子性只能靠硬件来完成,需要通过一条处理器指令来进行操作,这类指令常用的有测试并设置(Test-and-Set),获取并增加(Fetch-and-Increment),交换(Swap),比较并交换(Compare-and-Swap,下文称CAS),加载链接/条件存储(Load-Linked/Store-Conditional,下文称LL/SC)等。其中后面的两条是现代处理器新增的,而且这两条指令的目的和功能是类似的。
(3)CAS指令
java程序是在JDK1.5之后才是用CAS指令的,CAS指令需要有3个操作数,分别是内存位置(在Java中可以简单理解为变量的内存地 址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,当且仅当 V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新 了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。简单的说,就是它会先进行资源在工作内存中的更新,然后根据与主存中旧值的对比来确定在此期间是否有其他线程对共享资源进行了更新,如果旧值与期望值相同,就认为没有更新,可以把新值写回内存,否则就一直重试直到成功。
(4)CAS的漏洞
CAS从 语义上来说并不是完美的,存在这样的一个逻辑漏洞:如果一个变量V初次读取的时候是A 值,并且在准备赋值的时候检查到它仍然为A值,那我们就能说它的值没有被其他线程改变 过了吗?如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认 为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。J.U.C包为了解决这个问题, 提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本 来保证CAS的正确性。不过目前来说这个类比较“鸡肋”,大部分情况下ABA问题不会影响程 序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。

3 无同步

要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保证共享数 据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无须任何同步 措施去保证正确性,因此会有一些代码天生就是线程安全的。

3.1 可重入代码(Reentrant Code)

可重入代码是可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。相对线程安全来说,可重入性是更基本的特性,它可以保证线程安全,即所有的可重入的代码都是线程安全的,但是并非所有的线程安全的代码都 是可重入的。
可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源用到的状态量都由参数中传入不调用非可重入的方法等。我们可以通过一个简单的原则来判断代码是否具备可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数 据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

3.2 线程本地存储(Thread Local Storage)

如果一个共享资源一定要被多线程共享,可以尽量让一个线程完成所有的处理操作,比如生产者消费者模式中,一般会让一个消费者完成对队列上资源的消费。典型的应用是基于请求-应答模式的web服务器的设计。

java中实现线程本地存储

volatile关键字的作用

volatile是java多线程中数据操作可见性的保证,可见性指的是当线程修改了数据的状态时,能够立即被其他线程知晓,即数据修改后会立即写入主内存,后续其他线程读取时就能得知数据的变化。
volatile有两点特性,第一是保证变量对所有线程的可见性,对于普通变量,一个线程对其修改后并不保证会立即写入主存,只有当写入主存之后才会对其他线程可见,而volatile关键字能够保证线程修改完变量立即写回主存,而且每个线程在使用变量前都必须先从主存刷新数据,这样就保证了修改的可见性。第二点时禁止指令的重排序

java.lang.ThreadLocal类

每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以 ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就 是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的 threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。
这样说可能不明白ThreadLocal类是怎么回事?
通常情况下,我们创建的变量是可以被任何⼀个线程访问并修改的。如果想实现每⼀个线程都有⾃⼰的专属本地变量该如何解决呢? JDK 中提供的 ThreadLocal 类正是为了解决这样的问题。ThreadLocal 类主要解决的就是让每个线程绑定⾃⼰的值,可以将 ThreadLocal 类形象的⽐喻成存放数据的盒⼦,盒⼦中可以存储每个线程的私有数据。如果你创建了⼀个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是 ThreadLocal 变量名的由来。他们可以使⽤ get 和 set ⽅法来获取默认值或将其值更改为当前线程所存的副本的值,从⽽避免了线程安全问题。
⽐如有两个⼈去宝屋收集宝物,这两个共⽤⼀个袋⼦的话肯定会产⽣争执,但是给他们两个⼈每个⼈分配⼀个袋⼦的话就不会出现这样的问题。如果把这两个⼈⽐作线程的话,那么ThreadLocal 就是⽤来避免这两个线程竞争的。

问题1: 说说 synchronized 关键字和 volatile 关键字的区别?

synchronized 关键字和 volatile 关键字是两个互补的存在,⽽不是对⽴的存在!
性能:volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定⽐ synchronized 关键字要好。
变量的作用域: volatile 关键字只能⽤于变量⽽ synchronized 关键字可以修饰⽅法以及代码块。
可见性和原子性:volatile 关键字能保证数据的可⻅性,但不能保证数据的原⼦性。 synchronized 关键字两者都能保证。
应用:volatile 关键字主要⽤于解决变量在多个线程之间的可⻅性,⽽ synchronized 关键字解决的是多个线程之间访问资源的同步性。

前面我们说到互斥同步锁都是可重入锁,好处是可以保证不会死锁。但是因为涉及到核心态和用户态的切换,比较消耗性能。因此JVM开发团队在JDK5-JDK6升级过程中采用了很多锁优化机制来优化同步无竞争情况下锁的性能。比如:自旋锁和适应性自旋锁,轻量级锁,偏向锁,锁粗化和锁消除。

4 锁优化机制

4.1 自旋锁和适应性自旋锁(Adaptive Spinning)

(1)自旋锁

自旋锁解释:许多应用上,共享数据的锁定状态只会持续很短 的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器有一个以上的处理 器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一 下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
自旋锁缺点:自旋锁在JDK1.6中已经默认开启了,自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的, 因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间 很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然 没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,用户可以使用参数-XX:PreBlockSpin来更改。

(2)适应性自旋锁

适应性自旋锁的优点:在JDK 1.6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前 一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等 待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有 可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。另外,如果 对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避 免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对 程序锁的状况预测就会越来越准确,虚拟机就会变得越来越“聪明”了。

4.2 锁消除(Lock Elimination)

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

4.3 锁粗化(Lock Coarsening)

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

4.4 轻量级锁(Lightweight Locking)

轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传 统的重量级锁使用操作系统互斥量产生的性能消耗。
轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量。
轻量级锁适用于线程交替执行的同步块中,如果存在同一时间访问同一把锁的情况,如果锁竞争不激烈那么可以自旋,否则就将轻量级锁升级为重量级锁。

4.5 偏向锁(Biased Locking)

因为经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。所以偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。即这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束,偏向锁就会升级为轻量级锁。

问题1:说⼀说synchronized 关键字?

synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized 关键字可以保证被它修饰的⽅法或者代码块在任意时刻只能有⼀个线程执⾏。
另外,在 Java 早期版本中, synchronized 属于重量级锁,效率低下。因为Java 线程是映射到操作系统的原⽣线程之上的,如果要挂起或者唤醒⼀个线程,都需要操作系统帮忙完成,⽽操作系统实现线程之间的切换时需要从⽤户态转换到核心态,这个状态之间的转换需要耗费很多的处理器时间,时间成本相对较⾼。

但是在JDK1.6之后,加入了很多针对锁的优化措施:

比如说⾃旋锁、适应性⾃旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。这样让JDK6之后synchronized的性能优化大幅提升,也能保持一个和ReentrantLock相差不多的性能,所以JDK6之后还是提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。因此现在各种开源框架还是 JDK 源码都⼤量使⽤了 synchronized 关键字。

说到synchronized 关键字的使用:它主要有三种使用方式

(1)修饰实例⽅法: 作⽤于当前对象实例加锁,进⼊同步代码前要获得 当前对象实例的锁;
(2)修饰静态⽅法: 也就是给当前类加锁,会作⽤于类的所有对象实例 ,进⼊同步代码前要获得当前 class 的锁。因为静态成员不属于任何⼀个实例对象,是类成员( static 表明这是该类的⼀个静态资源,不管 new 了多少个对象,只有⼀份)。所以,如果⼀个线程 A 调⽤⼀个实例对象的⾮静态 synchronized ⽅法,⽽线程 B 需要调⽤这个实例对象所属类的静态 synchronized ⽅法,是允许的,不会发⽣互斥现象,因为访问静态 synchronized ⽅法占⽤的锁是当前类的锁,⽽访问⾮静态 synchronized ⽅法占⽤的锁是当前实例对象锁。
(3)修饰代码块 :指定加锁对象,对给定对象/类加锁。

synchronized 关键字的底层原理

首先,synchronized 修饰同步语句块的情况是,synchronized 在经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指明同步代码块的结束位置。当执⾏ monitorenter 指令时,线程试图获取锁也就是获取对象监视器 monitor 的持有权,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器加 1。在执⾏ monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外⼀个线程释放为⽌。另外, wait/notify 等⽅法也依赖于 monitor 对象,这就是为什么只有在同步的块或者⽅法中才能调⽤ wait/notify 等⽅法,否则会抛出 java.lang.IllegalMonitorStateException 的异常的原因。
其次,synchronized 修饰方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是ACC_SYNCHRONIZED 标识,该标识指明了该⽅法是⼀个同步⽅法。
但是,两者的本质都是对对象监视器 monitor 的获取。

问题2:公平锁和非公平锁?

公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。

参考文献:《深入理解java虚拟机》第二版——周志明

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值