大家好,我是一安,今天继续聊一下线程安全
什么是线程安全?
用《java concurrency in practice》中的一句话来表述:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其它的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。从这句话中我们可以知道几层意思:
-
线程安全是和对象密切绑定的; -
线程的安全性是由于线程调度和交替执行造成的; -
线程安全的目的是实现正确的结果
避免线程安全问题
由于CPU的执行速度和内存的存取速度严重不匹配,为了优化性能及充分利用运算能力,基于时间局部性、空间局部性等局部性原理,CPU在和内存间增加了多层高速缓存,当需要取数据时,CPU会先到高速缓存中查找对应的缓存是否存在,存在则直接返回,如果不存在则到内存中取出并保存在高速缓存中。
现在多核处理器越基本已经成为标配,这时每个处理器都有自己的缓存,这就带来了缓存一致性的问题:cpu计算时数据读取顺序优先级:寄存器->高速缓存->内存,计算过程中,有些数据可能被频繁读取,这些数据被存储在寄存器和高速缓存中,当线程计算完后,这些缓存的数据在适当的时候应该写回内存。
Java内存模型规定了所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必须是工作内存中进行,而不能直接读写主内存中的变量。线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。当多个线程同时读写某个内存数据时,各个线程都从主内存中获取数据,线程之间数据是不可见的,就可能会产生寄存器/高速缓存/内存之间的同步问题
如何破?
线程安全的前提是该变量是否被多个线程访问,只要有多于一个的线程操作给定的状态变量,此时就可能产生多线程问题。jvm层面避免线程安全问题主要围绕着并发过程中的原子性、可见性、有序性这三个特征
原子性
原子性就是操作不能被线程调度机制中断,要么全部执行完毕,要么不执行。
可见性
可见性就是一个线程对共享变量做了修改之后,其他的线程立即能够看到修改后的值。Java内存模型将工作内存中的变量修改后的值同步到主内存,在读取变量前从主内存刷新最新值到工作内存中,这种依赖主内存的方式来实现可见性的。
Java提供了volatile关键字来保证可见性。当对非volatile变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中,非volatile变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。而声明变量是volatile的,会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会跳过CPU cache这一步去内存中读取新值。volatile只确保了可见性,并不能确保原子性
有序性
为了尽可能减少内存操作速度远慢于CPU运行速度所带来的CPU空置的影响,编译器和处理器常常会对指令做重排序。
有序性:即程序执行的顺序按照代码的先后顺序执行。CPU虽然并不保证完全按照代码顺序执行,但它会保证程序最终的执行结果和代码顺序执行时的结果一致。重排序过程不会影响到单线程程序的执行,但会影响到多线程并发执行的正确性。通过volatile关键字来保证一定的“有序性”,volatile关键字本身就包含了禁止指令重排序的语义。另外可以通过synchronized和Lock来保证有序性,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性
synchronized
synchronized能够把任何一个非null对象当成锁,实现由两种方式:
-
类锁,当synchronized作用于静态方法时是给class加锁 -
对象锁,当synchronized作用于一个对象实例时或非静态方法时
synchronized锁又称为对象监视器(object)
当多个线程一起访问某个对象监视器的时候,对象监视器会将这些请求存储在不同的容器中
-
Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中 -
Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中 -
Wait Set:哪些调用wait方法被阻塞的线程被放置在这里 -
OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck -
Owner:当前已经获取到所资源的线程被称为Owner -
!Owner:当前释放锁的线程synchronized在jdk1.6之后提供了多种优化方案《你对Java中的锁了解多少,你又能说出几种锁?》
lock
与synchronized不同的是lock是纯java手写的,与底层的JVM无关。在java.util.concurrent.locks包中有很多Lock的实现类,常用的有ReenTrantLock、ReadWriteLock(实现类有ReenTrantReadWriteLock),其实现都依赖AbstractQueuedSynchronizer类(简称AQS),实现思路都大同小异,因此我们以ReentrantLock作为讲解切入点。主要从以下几个特点介绍:
-
可重入锁,如果锁具备可重入性,则称作为可重入锁。像synchronized和ReentrantLock都是可重入锁,可重入性在我看来实际上表明了锁的分配机制:基于线程的分配,而不是基于方法调用的分配。 -
可中断锁,顾名思义,就是可以相应中断的锁。在Java中,synchronized就不是可中断锁,而Lock是可中断锁。如果某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以让它中断自己或者在别的线程中中断它,这种就是可中断锁。 -
公平锁和非公平锁,公平锁以请求锁的顺序来获取锁,非公平锁则是无法保证按照请求的顺序执行。synchronized就是非公平锁,它无法保证等待的线程获取锁的顺序。而对于ReentrantLock和ReentrantReadWriteLock,它默认情况下是非公平锁,但是可以设置为公平锁。参数为true时表示公平锁,不传或者false都是为非公平锁。 -
读写锁,读写锁将对一个资源(比如文件)的访问分成了2个锁,一个读锁和一个写锁。正因为有了读写锁,才使得多个线程之间的读操作不会发生冲突。ReadWriteLock就是读写锁,它是一个接口,ReentrantReadWriteLock实现了这个接口。可以通过readLock()获取读锁,通过writeLock()获取写锁。
AQS:
从使用层面来讲,AQS 的功能分为两种:
-
独占锁:每次只能由一个线程持有锁。ReentrantLock 就是以独占方式实现的互斥锁; -
共享锁:允许多个线程同时获取锁,并发访问共享资源。比如 ReentrantReadWriteLock;
AQS 内部变量:
变量名 | 说明 | 关注层级 |
---|---|---|
state | 同步状态,标识当前状态是锁定、还是非锁定 | 重点 |
head | 指向 Node 节点,同步队列、等待队列的头节点指针 | 重点 |
tail | 指向 Node 节点,同步队列、等待队列的尾节点指针 | 重点 |
unsafe | Unsafe 类实例,实现线程 park、unpark 的关键 | 知道 |
stateOffset | state 字段在实例中的便宜量,用于 CAS 操作 | 了解 |
headOffset | head 字段在实例中的便宜量,用于 CAS 操作 | 了解 |
tailOffset | tail 字段在实例中的便宜量,用于 CAS 操作 | 了解 |
nextOffset | next 字段在实例中的便宜量,用于 CAS 操作 | 了解 |
工作原理介绍:
AQS 的实现依赖内部的 FIFO 的双向队列,如果当前线程竞争锁失败,那么AQS 会把当前线程以及等待状态信息构造成一个 Node 加入到这个队列中,同时调用 Unsafe 方法,使当前线程进入阻塞状态。当获取锁的线程释放了锁后,会从队列中唤醒下一个阻塞的节点(线程)
AQS 队列内部维护的是一个 FIFO 的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点,所以双向链表可以从任意一个节点开始很方便的访问前驱和后继。线程抢占的方式是通过 CAS 操作修改state的值,修改成功意味着当前线程抢占到了资源,修改失败的线程则加入 FIFO 队列,等待被唤醒。
Node 类组成如下:
static final class Node {
static final Node SHARED = new Node();
static final Node EXCLUSIVE = null;
/**该节点由于超时或中断而被取消,当前节点的线程作废*/
static final int CANCELLED = 1;
/**表明当前节点的下一节点的线程将要进入阻塞状态,需要被 unparking 唤醒*/
static final int SIGNAL = -1;
/**表明当前节点的线程 处于等待状态,也就是再等待队列,等待被通知*/
static final int CONDITION = -2;
static final int PROPAGATE = -3;
/**CANCELLED、SIGNAL、CONDITION、PROPAGATE*/
volatile int waitStatus;
volatile Node prev; //前驱节点
volatile Node next; //后继节点
volatile Thread thread;//当前线程
Node nextWaiter; //存储在condition队列中的后继节点
// 是否为共享锁
final boolean isShared() {
return nextWaiter == SHARED;
}
final Node predecessor() throws NullPointerException {
Node p = prev;
if (p == null)
throw new NullPointerException();
else
return p;
}
Node() {// Used to establish initial head or SHARED marker
}
// 将线程构造成一个Node,添加到等待队列
Node(Thread thread, Node mode) { // Used by addWaiter
this.nextWaiter = mode;
this.thread = thread;
}
// 这个方法会在Condition队列使用
Node(Thread thread, int waitStatus) { // Used by Condition
this.waitStatus = waitStatus;
this.thread = thread;
}
}
锁竞争、锁释放对队列的变化:
锁竞争:
新的线程没有抢到锁,加入 FIFO 队列线程 Thread0 抢占到了锁,线程 Thread1、Thread2、Thread3 抢占锁失败,进入 FIFO 同步队列,等待被唤醒
锁释放:
当锁释放的时候,会判断 head 节点的 waitStatus 的状态是不是 != 0,如果是,则说明 FIFO 队列有等待线程,唤醒Node1节点保存的线程(thread1)
threa0 释放了锁,判断 head 的 waitStatus = -1,则调用 unpack 唤醒第一个 node,也就是 thread1,这里 thread1 抢占到了锁
thread1未抢到锁,节点变化如下:threa0 释放了锁,判断 head 的 waitStatus = -1,则调用 unpack 唤醒第一个 node,也就是 thread1,这里 thread1 抢占到了锁失败,被新线程 thread3 抢占锁成功,那么 thread1 会重新进入 wait 状态,在 FIFO 同步队列中的位置是不变的
lock与synchronized区别
类别 | synchronized | Lock |
---|---|---|
存在层次 | Java的关键字,在jvm层面上 | 一个类 |
锁的释放 | 1、以获取锁的线程执行完同步代码,释放锁 2、线程执行发生异常,jvm会让线程释放锁 | 在finally中必须释放锁,不然容易造成线程死锁 |
锁的获取 | 假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待 | 分情况而定,Lock有多个锁获取的方式,具体下面会说道,大致就是可以尝试获得锁,线程可以不用一直等待 |
锁状态 | 无法判断 | 可以判断 |
锁类型 | 可重入 不可中断 非公平 | 可重入 可判断 可公平(两者皆可) |
性能 | 少量同步 | 大量同步 |
号外!号外!
如果这篇文章对你有所帮助,或者有所启发的话,帮忙点赞、在看、转发、收藏,你的支持就是我坚持下去的最大动力!
原文始发于微信公众号(一安未来):Java从线程安全到synchronized和Lock探索
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/44590.html