| |
|
开发:
C++知识库
Java知识库
JavaScript
Python
PHP知识库
人工智能
区块链
大数据
移动开发
嵌入式
开发工具
数据结构与算法
开发测试
游戏开发
网络协议
系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程 数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁 |
-> Java知识库 -> Java解决线程安全问题 -> 正文阅读 |
|
[Java知识库]Java解决线程安全问题 |
文章目录背景原文地址:https://duktig.cn/archives/36/ 1. 线程安全问题1.1 什么是线程安全?
“线程安全”不是指线程的安全,而是指内存的安全。为什么如此说呢?这和操作系统有关。 目前主流操作系统都是多任务的,即多个进程同时运行。为了保证安全,每个进程只能访问分配给自己的内存空间,而不能访问别的进程的,这是由操作系统保障的。但是进程中的多个线程共享进程的堆内存,这就是造成问题的潜在原因。 假设某个线程把数据处理到一半,觉得很累,就去休息了一会,回来准备接着处理,却发现数据已经被修改了,不是自己离开时的样子了。可能被其它线程修改了。 比如把你住的小区看作一个进程,小区里的道路/绿化等就属于公共区域。你拿1万块钱往地上一扔,就回家睡觉去了。睡醒后你打算去把它捡回来,发现钱已经不见了。可能被别人拿走了。因为公共区域人来人往,你放的东西在没有看管措施时,一定是不安全的。内存中的情况亦然如此。
即堆内存空间在没有保护机制的情况下,对多线程来说是不安全的地方,因为你放进去的数据,可能被别的线程“破坏”。 1.2 产生的原因
当多条语句在操作同一个线程共享数据时,一个线程对多条语句只执行了一部分,还没有执行完,另一个线程参与进来执行,导致共享数据的错误。 1.3 实例(买票超卖问题)
结果 出现了共享数据错误
分析
1.4 如何确定是否存在线程安全问题?
2. 如何解决线程安全问题?如何解决线程安全问题?解决的过程其实就是一个取舍的过程,不同的方案有不同的侧重点。 2.1 不可变(Immutable)不可变的对象一定是线程安全的,不需要采取任何的线程安全措施。只要一个不可变的对象被正确的构建出来,在多线程的状态下也不允许修改,那么一定不会发生不一致的状态。 形象比喻:只能看,不能摸。自然不会存在线程安全问题。 在多线程的环境下,应尽量是对象成为不可变的状态,来满足线程安全,这基本也是最小的开销方式来保证线程安全。
不可变的类型:
注:final修饰的引用数据类型不可变,但其成员变量的类可能会发生改变。 2.2 变量私有化要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。 2.2.1 栈封闭(主要为局部变量)多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。 形象比喻:私有的东西就不该让别人知道。 如果一些数据只有某个线程会使用,其它线程不能操作也不需要操作,这些数据就可以放入线程的栈内存中。较为常见的就是局部变量。
这里的变量 假如现在A线程来执行这个方法,这些变量会在A的栈内存分配。与此同时,B线程也来执行这个方法,这些变量也会在B的栈内存中分配。 也就是说这些局部变量会在每个线程的栈内存中都分配一份。由于线程的栈内存只能自己访问,所以栈内存中的变量只属于自己,其它线程根本就不知道,也就不存在线程安全问题。 问题: 不可能所有的变量都只声明成局部变量,而只供某个线程使用,去解决线程安全问题。如果想要声明成员变量,还想要保证线程安全怎么办? 可以使用 2.2.2 线程本地存储(Thread Local Storage)如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。 可以使用 通俗来说:要让公共区域堆内存中的数据对于每个线程都是安全的,那就每个线程都拷贝它一份,每个线程只处理自己的这一份拷贝而不去影响别的线程的,这不就安全了嘛。 形象比喻:大家不要抢,人人有份。 符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。 为了理解
它所对应的底层结构图为: 每个 Thread 都有一个
当调用一个
get() 方法类似。
在一些场景 (尤其是使用线程池) 下,由于 2.3 互斥同步前面给出的一些方案,有点“理想化”了,现实中的情况其实是非常混乱嘈杂的,没有规则的。 形象比喻:没有规则,那就先入为主。 例子: 比如在中午高峰期你去饭店吃饭,进门后发现只剩一个空桌子了,你心想先去点餐吧,回来就坐这里吧。当你点完餐回来后,发现已经被别人捷足先登了。 因为桌子是属于公共区域的物品,任何人都可以坐,那就只能谁先抢到谁坐。虽然你在人群中曾多看了它一眼,但它并不会记住你容颜。 解决方法就不用我说了吧,让一个人在那儿看着座位,其它人去点餐。这样当别人再来的时候,你就可以理直气壮的说,“不好意思,这个座位,我,已经占了”。 相信聪明的你已经猜到了我要说的东西了,没错,就是互斥锁 。 如果公共区域(堆内存)的数据,要被多个线程操作时,为了确保数据的安全(或一致)性,需要在数据旁边放一把锁,要想操作数据,先获取锁再说吧。 假设一个线程来到数据跟前一看,发现锁是空闲的,没有人持有。于是它就拿到了这把锁,然后开始操作数据。 这时,又来了一个线程,发现锁被别人持有着,按照规定,它不能操作数据,因为它无法得到这把锁。当然,它可以选择等待,或放弃,转而去干别的。 因为第一个线程持有锁,可以大胆干事而不用担心其他线程的影响。 对于互斥同步锁,可以使用
假定一个班级的初始分数是60分,这个班级抽出10名学生来同时参加10个不同的答题节目,每个学生答对一次为班级加上5分,答错一次减去5分。因为10个学生一起进行,所以这一定是一个并发情形。 因此加分和减分这两个方法被并发的调用,它们共同操作总分数。为了保证数据的一致性,需要在每次操作前先获取锁,操作完成后再释放锁。 问题: 互斥阻塞会有线程阻塞和唤醒所带来的性能问题。 2.4 非阻塞同步互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。 随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。 形象比喻:相信世界充满爱,即使被伤害。 由于无锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说乐观锁天生免疫死锁 。 乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;而悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能。 2.4.1 CAS例子解释: 例子,假如你往地上仍1万块钱,是不是一定会丢呢?这要看情况了,如果是在人来人往的都市,可以说肯定会丢的。如果你跑到无人区扔地上,可以说肯定不会丢。 可以看到,都是把东西无保护的放到公共区域里,结果却相差很大。这说明安全问题还和公共区域的环境状况有关系。 比如我把数据放到公共区域的堆内存中,但是始终都只会有1个线程,也就是单线程模型,那这数据肯定是安全的。 再者说,2个线程操作同一个数据和200个线程操作同一个数据,这个数据的安全概率是完全不一样的。肯定线程越多数据不安全的概率越大,线程越少数据不安全的概率越小。取个极限情况,那就是只有1个线程,那不安全概率就是0,也就是安全的。 因为锁的获取和释放是要花费一定代价的,如果在线程数目特别少的时候,可能可能就不会有别的线程来操作数据,此时你还要获取锁和释放锁,可以说是一种浪费。 针对这种“地广人稀”的情况,专门提出了一种方法,叫CAS。就是在并发很小的情况下,数据被意外修改的概率很低,但是又存在这种可能性,此时就用CAS。 CAS的全称是:比较并交换(Compare And Swap)。在CAS中,有这样三个值:
比较并交换的过程如下: 判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其它线程更新了V,则当前线程放弃更新,什么都不做。 我们以一个简单的例子来解释这个过程:
在这个例子中, 那有没有可能我在判断了 不会的。因为CAS是一种原子操作,它是一种系统原语,是一条CPU的原子指令,从CPU层面保证它的原子性。 CAS是一种原子操作,在Java中,有一个 当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。 ABA问题?(狸猫换太子) 因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了,那 CAS 操作就会误认为它从来没有被改变过。 ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。 Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。 2.4.2 Atomic(原子操作)Unsafe类支持CAS的方法。那Java具体是如何使用这几个方法来实现原子操作的呢? JDK提供了一些用于原子操作的类,在 从名字就可以看得出来这些类大概的用途:
3. 总结和分析“栈封闭”:找个只有自己知道的地方藏起来,当然安全了。 “ “不可变”:更狠了,直接规定,只能读取,禁止修改,当然也安全了。 互斥同步和非阻塞同步,分别对应悲观锁和乐观锁的策略。 参考 |
|
|
上一篇文章 下一篇文章 查看所有文章 |
|
开发:
C++知识库
Java知识库
JavaScript
Python
PHP知识库
人工智能
区块链
大数据
移动开发
嵌入式
开发工具
数据结构与算法
开发测试
游戏开发
网络协议
系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程 数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁 |
360图书馆 购物 三丰科技 阅读网 日历 万年历 2025年1日历 | -2025/1/9 0:36:33- |
|
网站联系: qq:121756557 email:121756557@qq.com IT数码 |