Java从线程安全到synchronized和Lock探索

掌握线程安全及多线程问题是我们编写高性能代码的基础,下面将从理论到实践,一层一层的解开。可以看我的另一篇史上最全java并发攻略

目录

1. 什么是线程安全?

2. 避免线程安全问题

2.1 原子性

2.2 可见性

2.3 有序性

3. synchronized方案

1. 锁升级

2.锁消除

4.锁粗化

4. lock方案

5. lock与synchronized区别

总结


1. 什么是线程安全?

我们用《java concurrency in practice 》中的一句话来表述:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其它的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。从这句话中我们可以知道几层意思:

  1. 线程安全是和对象密切绑定的;
  2. 线程的安全性是由于线程调度和交替执行造成的;
  3. 线程安全的目的是实现正确的结果

2. 避免线程安全问题

由于CPU的执行速度和内存的存取速度严重不匹配,为了优化性能及充分利用运算能力,基于时间局部性、空间局部性等局部性原理,CPU在和内存间增加了多层高速缓存,当需要取数据时,CPU会先到高速缓存中查找对应的缓存是否存在,存在则直接返回,如果不存在则到内存中取出并保存在高速缓存中。

现在多核处理器越基本已经成为标配,这时每个处理器都有自己的缓存,这就带来了缓存一致性的问题:cpu计算时数据读取顺序优先级:寄存器->高速缓存->内存,计算过程中,有些数据可能被频繁读取,这些数据被存储在寄存器和高速缓存中,当线程计算完后,这些缓存的数据在适当的时候应该写回内存。

Java内存模型规定所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必须是工作内存中进行,而不能直接读写主内存中的变量。线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。当多个线程同时读写某个内存数据时,各个线程都从主内存中获取数据,线程之间数据是不可见的,就可能会产生寄存器/高速缓存/内存之间的同步问题。

如何破?

线程安全的前提是该变量是否被多个线程访问,只要有多于一个的线程操作给定的状态变量,此时就可能产生多线程问题。jvm层面避免线程安全问题主要围绕着并发过程中的原子性、可见性、有序性这三个特征。

2.1 原子性

原子性就是操作不能被线程调度机制中断,要么全部执行完毕要么不执行。java内存模型确保基本类型数据的访问大都是原子操作,即多个线程在并发访问的时候是线程非安全的。比如”a = 2”、 “return a;”都具有原子性。但是类似”a += b”、”i++”的操作不具有原子性。

注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性,这就导致了long、double类型的变量在32位虚拟机中是非原子操作。

可以使用AtomicXXX、synchronized和Lock保证原子性。synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,自然就不存在原子性问题了。

2.2 可见性

可见性就是一个线程对共享变量做了修改之后,其他的线程立即能够看到修改后的值。Java内存模型将工作内存中的变量修改后的值同步到主内存,在读取变量前从主内存刷新最新值到工作内存中,这种依赖主内存的方式来实现可见性的。

Java提供了volatile关键字来保证可见性。当对非volatile变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中,非volatile变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。而声明变量是volatile的,会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会跳过CPU cache这一步去内存中读取新值。volatile只确保了可见性,并不能确保原子性

2.3 有序性

为了尽可能减少内存操作速度远慢于CPU运行速度所带来的CPU空置的影响,编译器和处理器常常会对指令做重排序。有序性:即程序执行的顺序按照代码的先后顺序执行。CPU虽然并不保证完全按照代码顺序执行,但它会保证程序最终的执行结果和代码顺序执行时的结果一致。重排序过程不会影响到单线程程序的执行,但会影响到多线程并发执行的正确性。通过volatile关键字来保证一定的“有序性”,volatile关键字本身就包含了禁止指令重排序的语义。另外可以通过synchronized和Lock来保证有序性,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

3. synchronized方案

1.synchronized能够把任何一个非null对象当成锁,实现由两种方式:

  • 类锁,当synchronized作用于静态方法时是给class加锁
  • 对象锁,当synchronized作用于一个对象实例时或非静态方法时

2.synchronized锁又称为对象监视器(object)。
3.当多个线程一起访问某个对象监视器的时候,对象监视器会将这些请求存储在不同的容器中。

  • Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中
  • Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中
  • Wait Set:哪些调用wait方法被阻塞的线程被放置在这里
  • OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck
  • Owner:当前已经获取到所资源的线程被称为Owner
  •  !Owner:当前释放锁的线程

synchronized在jdk1.6之后提供了多种优化方案

1. 锁升级

jvm自动进行偏向锁->轻量级锁->重量级锁升级的过程

2.锁消除

即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除,依据来源于逃逸分析的数据支持,那么是什么是逃逸分析?对于虚拟机来说需要使用数据流分析来确定是否消除变量底层框架的同步代码,因为有许多同步的代码不是自己写的。

public static String concatString(String s1, String s2, String s3) {  
    return s1 + s2 + s3;  
}  
/**
 *由于 String 是一个不可变的类,对字符串的连接操作总是通过生成新的 String 对象来进行的,因此 Javac 编译器会对 String 连接做自动优化。在 JDK 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作,在 JDK 1.5 及以后的版本中,会转化为 StringBuilder 对象的连续 append() 操作,这里的stringBuilder.append是线程不同步的(假设是同步)
 */
public static String concatString(String s1, String s2, String s3) {  
    StringBuffer sb = new StringBuffer();  
    sb.append(s1);  
    sb.append(s2);  
    sb.append(s3);  
    return sb.toString();  
}

4.锁粗化

将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。

4. lock方案

与synchronized不同的是lock是纯java手写的,与底层的JVM无关。在java.util.concurrent.locks包中有很多Lock的实现类,常用的有ReenTrantLock、ReadWriteLock(实现类有ReenTrantReadWriteLock),其实现都依赖AbstractQueuedSynchronizer类(简称AQS),实现思路都大同小异,因此我们以ReentrantLock作为讲解切入点。

AQS是我们后面将要提到的CountDownLatch/FutureTask/ReentrantLock/RenntrantReadWriteLock/Semaphore的基础,因此AQS也是Lock和Excutor实现的基础。它的基本思想就是一个同步器,支持获取锁和释放锁两个操作。

要支持上面锁获取、释放锁就必须满足下面的条件:

  • 状态位必须是原子操作的
  • 阻塞和唤醒线程
  • 一个有序的队列,用于支持锁的公平性

场景:可定时的、可轮询的与可中断的锁获取操作,公平队列,或者非块结构的锁。
主要从以下几个特点介绍:

  1. 可重入锁,如果锁具备可重入性,则称作为可重入锁。像synchronized和ReentrantLock都是可重入锁,可重入性在我看来实际上表明了锁的分配机制:基于线程的分配,而不是基于方法调用的分配。
  2. 可中断锁,顾名思义,就是可以相应中断的锁。在Java中,synchronized就不是可中断锁,而Lock是可中断锁。如果某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以让它中断自己或者在别的线程中中断它,这种就是可中断锁。
  3. 公平锁和非公平锁,公平锁以请求锁的顺序来获取锁,非公平锁则是无法保证按照请求的顺序执行。synchronized就是非公平锁,它无法保证等待的线程获取锁的顺序。而对于ReentrantLock和ReentrantReadWriteLock,它默认情况下是非公平锁,但是可以设置为公平锁。参数为true时表示公平锁,不传或者false都是为非公平锁。
  4. 读写锁,读写锁将对一个资源(比如文件)的访问分成了2个锁,一个读锁和一个写锁。正因为有了读写锁,才使得多个线程之间的读操作不会发生冲突。ReadWriteLock就是读写锁,它是一个接口,ReentrantReadWriteLock实现了这个接口。可以通过readLock()获取读锁,通过writeLock()获取写锁。

5. lock与synchronized区别

类别

synchronized

Lock

存在层次

Java的关键字,在jvm层面上

是一个类

锁的释放

1、以获取锁的线程执行完同步代码,释放锁 2、线程执行发生异常,jvm会让线程释放锁

在finally中必须释放锁,不然容易造成线程死锁

锁的获取

假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待

分情况而定,Lock有多个锁获取的方式,具体下面会说道,大致就是可以尝试获得锁,线程可以不用一直等待

锁状态

无法判断

可以判断

锁类型

可重入 不可中断 非公平

可重入 可判断 可公平(两者皆可)

性能

少量同步

大量同步

总结

1.synchronized
优点:实现简单,语义清晰,便于JVM堆栈跟踪,加锁解锁过程由JVM自动控制,提供了多种优化方案,使用更广泛
缺点:不能进行高级功能
2.lock
优点:可定时的、可轮询的与可中断的锁获取操作,提供了读写锁、公平锁和非公平锁
缺点:需手动释放锁unlock,不适合JVM进行堆栈跟踪
3.相同点
都是可重入锁

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值