热门标签 | HotTags
当前位置:  开发笔记 > 开放平台 > 正文

Java多线程之CAS算法实现线程安全

这篇文章主要介绍了java中如何通过CAS算法实现线程安全,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,下面小编和大家一起来学习一下吧

前言

对于线程安全,我们有说不尽的话题。大多数保证线程安全的方法是添加各种类型锁,使用各种同步机制,用限制对共享的、可变的类变量并发访问的方式来保证线程安全。文本从另一个角度,使用“比较交换算法”(CompareAndSwap)实现同样的需求。我们实现一个简单的“栈”,并逐步重构代码来进行讲解。

本文通俗易懂,不会涉及到过多的底层知识,适合初学者阅读(言外之意是各位大神可以绕道了)。

旅程开始

1.先定个小目标,实现一个“栈”

“栈”(stack)是大家经常使用的抽象数据类型(啥?!不知道,请自行百度)。“栈”满足“后进先出”特性。我们用链表数据结构完成一个简单的实现:

public class Stack {
//链表结构头部节点
private Node head;
/**
* 入栈
* @param item
*/
public void push(E item) {
//为新插入item创建一个新node
Node newHead = new Node<>(item);
if(head!=null){
//将新节点的下一个节点指向原来的头部
newHead.next = head;
}
//将头部指向新的节点
head=newHead;
}
/**
* 出栈
* @return
*/
public E pop() {
if(head==null){
//当前链表为空
return null;
}
//暂存当前节点。
Node oldHead=head;
//将当前节点指向当前节点的下一个节点
head=head.next;
//从暂存的当前节点记录返回数据
return oldHead.item;
}
/**
* 链表中的节点
* @param 
*/
private static class Node {
//节点保存的数据
public final E item;
//指向下一个链表中下一个节点
public Node next;
public Node(E item) {
this.item = item;
}
}
}

代码使用链表数据结构实现“栈”,在Stack中维护一个链表的“头部节点”,通过对头部节点的操作完成入栈和出栈操作。

我们运行代码测试一下:

public static void main(String[] args) {
Stack stack=new Stack<>();
for (int i = 0; i <3; i++) {
//入栈1、2、3
stack.push(i+1);
}
for (int i = 0; i <3; i++) {
//出栈3、2、1
System.out.println(stack.pop());
}
}

结果为:

3
2
1

我们使用入栈方法向Stack插入1、2、3,使用出栈方法打印为3、2、1,符合预期。

2.让多线程捣捣乱

前面我们已经测试过我们的方法,符合我们对Stack功能的预期,那是不是任何情况先我们的“栈”都能正常工作呢?

我们运行如下代码:

public static void main(String[] args) {
Stack stack=new Stack<>();
int max=3;
Thread[] threads=new Thread[max];
for (int i = 0; i 

你可能运行了很多次,每次运行时除了打印顺序(3、2、1或2、3、1或1、2、3)有变化之外也没有发现其他异常,你可能会说打印顺序变化很正常呀,因为我们的将入栈操作放到异步线程中操作,三个线程的执行过程由系统调度,所以入栈操作的内容自然每次都有可能不同。

好吧,你说的没错,至少从大量运行的结果上看是这样的,但是这就是多线程编程的奇(tao)幻(yan)之处,也许你运行一次没有问题,两次没有问题,一万次也没有问题,但是终有一次你会得到那个意想不到的结果(你也不想得到,因为那是bug)。这就像一个“黑天鹅事件”,小概率但是一定会发生,且发生后对你的系统影响不堪设想。
下面让我带你看看如何得到意料之外的结果:

我们使用调试模式运行上面的程序在Stack中push()方法第一行打一个断点,然后按照表格中的顺序切换不同的线程以单步调试(step over)方式运行run方法中的每一步,直到遇到Resume。

执行顺序 thread-0 thread-1 thread-2
1 Node newHead = new Node<>(item); -- --
2 head=newHead; -- --
3 (Resume) -- --
4 -- Node newHead = new Node<>(item); --
5 -- -- Node newHead = new Node<>(item);
6 -- newHead.next = head; --
7 -- -- newHead.next = head;
8 -- head=newHead; --
9 -- -- head=newHead;
10 -- (Resume)
11 -- -- (Resume)

当你再次看到打印结果,你会发现结果为3、1、null,“黑天鹅”出现了。

异常结果是如何产生的?

1.当thread-0执行到顺序3时,head表示的链表为node(1)。

2.当thread-1执行到顺序10时,head表示的链表为node(2)->node(1)。

3.当thread-2执行到顺序11时,head表示的链表为node(3)->node(1)。

当三个线程都执行完毕之后,head的最终表示为node(3)->node(1),也就是说thread-2将thread-1的执行结果覆盖了。

语句newHead.next = head;是对头部节点的读取。语句head=newHead;是对头部节点的写入操作。这两条语句组成了一个“读取——设置——写入”语句模式(就像n=n+1)。
如果一个线程执行了共享头部变量读取语句,切换其他线程执行了修改共享变量的值,再切回到第一个线程后,第一个线程中修改头部结点的数据就不是最新的数据为依据的,所以修改之后其他线程的修改就被覆盖了。
只有保证这两条语句及中间语句以原子方式执行,才能避免多线程覆盖问题。
大家可以任意调整代码中读取头部节点和写入头部节点的调试顺序,制造多线程交错读写观察不同的异常结果。

为什么我们直接执行无法看到异常结果呢?

因为我们的run方法很简单,在CPU分配的时间片内能运行完,没有出现在不同的运行周期中交错运行的状态。所以我们才要用调试模式这种交错运行。

为什么上文中我说过这种异常一定会发生?

原因在于我们在Stack类中对共享的、可变的变量head进行的多线程读写操作。

怎么才能保证类Stack在多线程情况下运行正确?

引用一段《JAVA并发编程实践》中的话:

无论何时,只要有多于一个的线程访问给定的状态变量,而且其中某个线程会写入该变量,此时必须使用同步来协调线程对该变量的访问。

好吧,看来我们必须采用“同步”方法了,来保障我们的Stack类在多线程并行和单线程串行的情况下都有正确的结果,也就是说将Stack变成一个线程安全的类。

3.让你捣乱,请家长!

既然多线程总来捣乱,我们就请他的家长,让家长管管他,守守规矩,不在捣乱。
我们已经知道了Stack类问什么不能再多线程下正确的运行的原因,所有我们要限制多线程对Stack类中head变量的并发写入,Stack方法中push()和pop()方法都会对head进行写操作,所以要限制这两个方法不能多线程并发访问,所以我们想到了synchronized关键字。

程序重构:

public class SynchronizedStack {
//链表结构头部节点
private Node head;
/**
* 入栈
* @param item
*/
public synchronized void push(E item) {
//为新插入item创建一个新node
Node newHead = new Node<>(item);
if(head!=null){
//将新节点的下一个节点指向原来的头部
newHead.next = head;
}
//将头部指向新的节点
head=newHead;
}
/**
* 出栈
* @return
*/
public synchronized E pop() {
if(head==null){
//当前链表为空
return null;
}
//暂存当前节点。
Node oldHead=head;
//将当前节点指向当前节点的下一个节点
head=head.next;
//从暂存的当前节点记录返回数据
return oldHead.item;
}
/**
* 链表中的节点
* @param 
*/
private static class Node {
//节点保存的数据
public final E item;
//指向下一个链表中下一个节点
public Node next;
public Node(E item) {
this.item = item;
}
}
}

Stack类替换为SynchronizedStack类的测试方法。

public static void main(String[] args) {
SynchronizedStack stack=new SynchronizedStack<>();
int max=3;
Thread[] threads=new Thread[max];
for (int i = 0; i 

我们再次运行第二章为多线程准备的测试方法,发现当执行一个线程的方法时,其他线程的方法均被阻塞,只能等到第一个线程方法执行完成之后才能执行其他线程方法。

我们只不过是在push()pop()方法上加入了synchronized 关键字,就将这两个方法编程了同步方法,在多线程并发的情况下也如同单线程串行调用一般,方法再不能在线程间交替运行,也就不能对head变量做并发更改了,这样修改的Stack类就是线程安全的了。

除了synchronized关键字,还有其他的方式实现加锁吗?

除了synchronized关键字还可以使用java.util.concurrent.locks包中各种锁来保证同步,但是大概思路都是相同的,都是使用阻塞其他线程的方式在达到防止并发写入的目的。

阻塞线程是否会影响执行效率?

如果和不加通过的“栈”类相比,在多线程执行的之后效率一定会有影响,因为同步方法限制了线程之间的并发性,但是为了保证“栈”类的在多线程环境时功能正确,我们不得不做出效率和正确性的权衡。

必须要对整个方法加上锁吗?

我们上面已经分析了需要加锁的范围,只要保证读取头部节点和写入头部节点之间的语句原子性就可以。所以我们可以这样执行。

/**
* 入栈
*
* @param item
*/
public void push(E item) {
//为新插入item创建一个新node
Node newHead = new Node<>(item);
synchronized (this) {
if (head != null) {
//将新节点的下一个节点指向原来的头部
newHead.next = head;
}
//将头部指向新的节点
head = newHead;
}
}
/**
* 出栈
*
* @return
*/
public E pop() {
synchronized (this) {
if (head == null) {
//当前链表为空
return null;
}
//暂存当前节点。
Node oldHead = head;
//将当前节点指向当前节点的下一个节点
head = head.next;
//从暂存的当前节点记录返回数据
return oldHead.item;
}
}

通过synchronized块实现,因为方法比较简单,所以也没有很明显的缩小加锁范围。

除了加锁的方式,是否还有其他方式?

当然,我们还有无锁化编程来解决线程之间同步的问题。这就是下面要介绍的比较交换算法。

4.换个思路,乐观一点

加锁实现线程同步的方式是预防性方式。无论共享变量是否会被并发修改,我们都只允许同一时刻只有一个线程运行方法来阻止并发发生。这就相当于我们假设并发一定会发生,所以比较悲观。

现在我们换一种思路,乐观一点,不要假设对变量的并发修改一定发生,这样也就不用对方法加锁阻止多线程并行运行方法了。但是一旦发生了并发修改,我们想法发解决就是了,解决的方法就是将这个操作重试一下。

继续重构“栈”代码:

public class TreiberStack {
private AtomicReference> headNode = new AtomicReference<>();
public void push(E item) {
Node newHead = new Node<>(item);
Node oldHead;
do {
oldHead = headNode.get();
newHead.next = oldHead;
} while (!headNode.compareAndSet(oldHead, newHead));
}
public E pop() {
Node oldHead;
Node newHead;
do {
oldHead = headNode.get();
if (oldHead == null)
return null;
newHead = oldHead.next;
} while (!headNode.compareAndSet(oldHead, newHead));
return oldHead.item;
}
private static class Node {
public final E item;
public Node next;
public Node(E item) {
this.item = item;
}
}
}

这个就是大名鼎鼎的Treiber Stack,我也只是做了一次代码的搬运工。
我们来看看TreiberStack和我们前面的Stack有什么不同。

首先关注第一行:

private AtomicReference> headNode = new AtomicReference<>();

我们用了一个AtomicReference类存储链表的头部节点,这个类可以获取存储对象的最新值,并且在修改存储值时候采用比较交换算法保证原子操作,具体大家可以自行百度。

然后重点关注pop()push()方法中都有的一个代码结构:

//略...
do {
oldHead = headNode.get();
//略...
} while (!headNode.compareAndSet(oldHead, newHead));
//略...

我们AtomicReferenceget()方法最新的获取头部节点,然后调用AtomicReferencecompareAndSet()将设置新头部节点,如果当前线程执行这两端代码的时候如果有其他已经修改了头部节点的值,'compareAndSet()'方法返回false ,表明修改失败,循环继续,否则修改成功,跳出循环。

这样一个代码结构和synchronized关键字修饰的方法一样,都保证了对于头部节点的读取和写入操作及中间代码在一个线程下原子执行,前者是通过其他线程修改过就重试的方式,后者通过阻塞其他线程的方式,一个是乐观的方式,一个是悲观的方式。

大家可以按照前面的例子自己写测试方法测试。

后记

我们通过对“栈”的一步一步代码重构,逐步介绍了什么是线程安全及保证线程安全的各种方法。这里需要说明一点,对于一个类来说,是否需要支持线程安全是由类的使用场景决定,不是有类所提供的功能决定的,如果一个类不会被应用于多线程的情况下也就无需将他转化为线程安全的类。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。


推荐阅读
  • Android中高级面试必知必会,积累总结
    本文介绍了Android中高级面试的必知必会内容,并总结了相关经验。文章指出,如今的Android市场对开发人员的要求更高,需要更专业的人才。同时,文章还给出了针对Android岗位的职责和要求,并提供了简历突出的建议。 ... [详细]
  • “你永远都不知道明天和‘公司的意外’哪个先来。”疫情期间,这是我们最战战兢兢的心情。但是显然,有些人体会不了。这份行业数据,让笔者“柠檬” ... [详细]
  • 生成对抗式网络GAN及其衍生CGAN、DCGAN、WGAN、LSGAN、BEGAN介绍
    一、GAN原理介绍学习GAN的第一篇论文当然由是IanGoodfellow于2014年发表的GenerativeAdversarialNetworks(论文下载链接arxiv:[h ... [详细]
  • 无线认证设置故障排除方法及注意事项
    本文介绍了解决无线认证设置故障的方法和注意事项,包括检查无线路由器工作状态、关闭手机休眠状态下的网络设置、重启路由器、更改认证类型、恢复出厂设置和手机网络设置等。通过这些方法,可以解决无线认证设置可能出现的问题,确保无线网络正常连接和上网。同时,还提供了一些注意事项,以便用户在进行无线认证设置时能够正确操作。 ... [详细]
  • 本文详细介绍了相机防抖的设置方法和使用技巧,包括索尼防抖设置、VR和Stabilizer档位的选择、机身菜单设置等。同时解释了相机防抖的原理,包括电子防抖和光学防抖的区别,以及它们对画质细节的影响。此外,还提到了一些运动相机的防抖方法,如大疆的Osmo Action的Rock Steady技术。通过本文,你将更好地理解相机防抖的重要性和使用技巧,提高拍摄体验。 ... [详细]
  • 图解redis的持久化存储机制RDB和AOF的原理和优缺点
    本文通过图解的方式介绍了redis的持久化存储机制RDB和AOF的原理和优缺点。RDB是将redis内存中的数据保存为快照文件,恢复速度较快但不支持拉链式快照。AOF是将操作日志保存到磁盘,实时存储数据但恢复速度较慢。文章详细分析了两种机制的优缺点,帮助读者更好地理解redis的持久化存储策略。 ... [详细]
  • 解决Cydia数据库错误:could not open file /var/lib/dpkg/status 的方法
    本文介绍了解决iOS系统中Cydia数据库错误的方法。通过使用苹果电脑上的Impactor工具和NewTerm软件,以及ifunbox工具和终端命令,可以解决该问题。具体步骤包括下载所需工具、连接手机到电脑、安装NewTerm、下载ifunbox并注册Dropbox账号、下载并解压lib.zip文件、将lib文件夹拖入Books文件夹中,并将lib文件夹拷贝到/var/目录下。以上方法适用于已经越狱且出现Cydia数据库错误的iPhone手机。 ... [详细]
  • 计算机存储系统的层次结构及其优势
    本文介绍了计算机存储系统的层次结构,包括高速缓存、主存储器和辅助存储器三个层次。通过分层存储数据可以提高程序的执行效率。计算机存储系统的层次结构将各种不同存储容量、存取速度和价格的存储器有机组合成整体,形成可寻址存储空间比主存储器空间大得多的存储整体。由于辅助存储器容量大、价格低,使得整体存储系统的平均价格降低。同时,高速缓存的存取速度可以和CPU的工作速度相匹配,进一步提高程序执行效率。 ... [详细]
  • 本文介绍了求解gcdexgcd斐蜀定理的迭代法和递归法,并解释了exgcd的概念和应用。exgcd是指对于不完全为0的非负整数a和b,gcd(a,b)表示a和b的最大公约数,必然存在整数对x和y,使得gcd(a,b)=ax+by。此外,本文还给出了相应的代码示例。 ... [详细]
  • 本文讨论了同事工资打听的话题,包括同工不同酬现象、打探工资的途径、为什么打听别人的工资、职业的本质、商业价值与工资的关系,以及如何面对同事工资比自己高的情况和凸显自己的商业价值。故事中的阿巧发现同事的工资比自己高后感到不满,通过与老公、闺蜜交流和搜索相关关键词来寻求解决办法。 ... [详细]
  • Python实现变声器功能(萝莉音御姐音)的方法及步骤
    本文介绍了使用Python实现变声器功能(萝莉音御姐音)的方法及步骤。首先登录百度AL开发平台,选择语音合成,创建应用并填写应用信息,获取Appid、API Key和Secret Key。然后安装pythonsdk,可以通过pip install baidu-aip或python setup.py install进行安装。最后,书写代码实现变声器功能,使用AipSpeech库进行语音合成,可以设置音量等参数。 ... [详细]
  • 知识图谱——机器大脑中的知识库
    本文介绍了知识图谱在机器大脑中的应用,以及搜索引擎在知识图谱方面的发展。以谷歌知识图谱为例,说明了知识图谱的智能化特点。通过搜索引擎用户可以获取更加智能化的答案,如搜索关键词"Marie Curie",会得到居里夫人的详细信息以及与之相关的历史人物。知识图谱的出现引起了搜索引擎行业的变革,不仅美国的微软必应,中国的百度、搜狗等搜索引擎公司也纷纷推出了自己的知识图谱。 ... [详细]
  • 本文介绍了数模国赛的报名参加方法,包括学校报名和自己报名的途径。同时给出了建模竞赛的建议,重在历练的同时掌握方法以及弥补自己的短板。此外,还分享了论文的结构和模型求解部分的注意事项,包括数学命题的表述规范和计算方法的原理等。 ... [详细]
  • 本文介绍了高校天文共享平台的开发过程中的思考和规划。该平台旨在为高校学生提供天象预报、科普知识、观测活动、图片分享等功能。文章分析了项目的技术栈选择、网站前端布局、业务流程、数据库结构等方面,并总结了项目存在的问题,如前后端未分离、代码混乱等。作者表示希望通过记录和规划,能够理清思路,进一步完善该平台。 ... [详细]
  • 本文介绍了一些好用的搜索引擎的替代品,包括网盘搜索工具、百度网盘搜索引擎等。同时还介绍了一些笑话大全、GIF笑话图片、动态图等资源的搜索引擎。此外,还推荐了一些迅雷快传搜索和360云盘资源搜索的网盘搜索引擎。 ... [详细]
author-avatar
一个不起眼的小人物
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有