#java多线程编程难点在哪里# 线程安全问题是一个相当大的难点, 之所以是这样, 主要是因为当多个线程去同时访问那些共享资源的时候, 是非常容易出错的。在今天这个时间段里, 咱们可以好好地来聊一聊并发的程序编写这个话题。
多线程开发这是一个非常棘手的难题。就算你拥有非常长多年的实践经验, 仍然会发生各种各样的踩坑情况。我现在打算把我个人对于Java多线程开发过程中的锁机制的理解和运用进行一个详细的总结。
我的想法是希望通过这些内容能够提供一点帮助, 使得大家能够在自己平时的日常工作中都能够轻松地应对和处理各种各样的并发编程的相关问题和情况。
这篇文章的内容涵盖了我们前面提到的那些要点, 在代码层面, 我们则是凭直觉随手写了一点小测试, 为了确保这些代码始终是最新的并且能够顺利运行, 我们在结尾处还对各种锁的具体应用场景做了一个汇总, 大家如果有兴趣的话, 欢迎随时留下评论, 我们一起交流心得。
当你正在浏览面试中那些被广泛传抄的技术要点时, 你很可能会碰到这样的题目, 即需要去解释在涉及多个线程同时运行的场景里, 应该用什么办法来确保数据能够对被其他线程观察到, 以及要怎样维持存储在内存中的数据状态处于同步一致的状态。
答案是, 一般会用关键字, 而且还会去解释一下它的核心作用。这个关键字的核心作用就是保证变量的可见性。当一个线程对用这个关键字修饰的变量做了修改操作以后, 别的线程那边, 就能够立马看到这种变化的存在了。
大家都晓得线程是具有自身的本地缓存的, 常规的变量通常都是被存放于线程缓存之中的, 而加上了修饰符号的变量, 则不同它的读取和写入这些操作就直接从内存那里去获取数据了, 这样的话就确保了在涉及到多个线程的共同工作这样一种环境背景下, 关于那个变量的最新的值能够让全部参与工作的线程都清楚并且看到。
至于那些回答得还要再深入一点的内容, 它们还明确提出了另外一层意思, 也就是说, 这一做法还可以防止出现指令被重排的那种情况。
那么大家所提到的这个指令重排, 它具体指的是一种什么样的情况呢?
Java里面的那些编译器和处理程序, 它们为了能够让代码在运行的时候性能变得更好一点, 所以就可能会自己把指令要执行的先后顺序给重新安排一下。可是你要是把它们放进到那个多线程的环境里面去的话, 那么这种指令重排的动作就很有可能会出现一些谁也没有料想到的情况了。
指令重排的情况并非一定会发生。在jdk8这个版本出现之后呢, java方面针对编译器以及处理器都做了一系列的优化动作, 同时也对内存模型这个层面进行了更安全的优化操作。
这些改动使得指令重排实际发生的概率有所降低, 但是需要注意的是, 它并不能够被完全杜绝掉。我们可以借由下面这个具体的案例来进行观察和分析:
如果我们按照代码的顺序来依次执行, x 和 y 这两个值是一定不可能同时都为 0 的。因此, 第 26 行那里的代码是根本进不去的。不管线程 one 和 two 是按顺序一个接一个地运行, 还是把它们两个的运行过程交叉在一起运行, 最终都会至少有一个值为 1:
关于指令重排会出现什么样的状况呢? 其中最有可能出现的指令重排现象, 就是线程中的代码语句执行顺序发生了改变。
举个例子来说, 在线程 one 当中, 赋值操作 a=1 和操作 x=b 的执行顺序发生了调换, 之所以会发生调换, 是因为在这两个操作之间不存在依赖关系, 即使在单线程的环境下面, 无论是先执行哪一条语句, 最终的结果都不会受到影响。
可是, 在指令执行顺序被重新安排之后, 在我们的范例代码里面, 由于多线程之间存在互相影响的情况, 所以可能会出现 x 等于 0 同时 y 也等于 0 的情形。
运行结果如下:
在JDK21的环境下进行运行, 整整跑过了三万多次之后, 才会出现重排的情况发生, 尽管说这个发生的概率是非常之低的, 但是终究还是不可避免地会出现这种现象的。
因此, 当我们对那个变量追加一些修饰的时候, 由此带来的那种指令乱序的操作也就不会得以产生了。
如果你保持其它代码不动, 然后再去运行这段代码, 那么接下来的这个循环就绝对不会再停下来。
那么, 为什么我们能够确保指令的顺序不会发生改变呢? 这个情况是涉及到一个叫做内存屏障的概念的。
内存屏障它属于一种同步机制, 其主要用途是为了保证那些操作之间的顺序性, 具体来说就是某个时间点发生的内存操作, 它一定是发生在它之前的所有同类型操作都被完成之后才会去执行, 或者另一种理解方式是, 它之后的那一些会影响到它的操作不会跑到前面去执行。
内存屏障这个东西, 是由Java编译器还有处理器自己来进行判断的, 它们会自行选择插入的位置。比如说如果你是用到了某些特定的关键字, 或者是使用了Lock这个接口之类的操作, 那么这些操作背后会被隐式地就插入上内存屏障, 用户根本不用担心需要手动去处理这些底层的细节问题。
在Java里面, 针对内存屏障的划分, 其实是有四种不同分类的情况存在的:
可能会觉得阅读起来略微有些吃力, 所以, 我们依然选择利用先前提供的那几行代码来进行详细的解析和解释:
线程 one 里面的 a = 1, 这是一个写操作。x = b 是一个复合操作, 其中还包含一个读 b 的操作。线程 two 也是同样的情况。
如果你想要去回答那个原因, 可以这样来说明: 指令是不会被重新排列的, 这一点是可以确保的。
1. 它通过内存屏障来实现防止指令重排的作用, 在写操作后插入栅栏、屏障, 在读操作之前插入 、栅。
2. 要通过特定的规则, 来确保对于变量的全局读写顺序, 是不会发生重排的情况的。
的局限
它并不是万能的, 它仅仅是确保了变量的可见性, 并且防止了指令重排的事情发生, 但是, 它并不是同步锁, 所以无法确保操作的原子性。
试一下那个最简单的自增运算操作。
在这个步骤里, 我们尝试的次数需要设置得大一些, 原因在于在多线程环境下, 产生资源竞争的情况是有概率发生的。
出现这种情况, 就是老生常谈的: 自增操作不是原子操作, 虽然加了修饰变量, 当它实际上是分为三步进行, 先从内存读取, 然后加一, 最后才写回内存, 在多线程的情况下, 这三步可能会交叉进行, 顺序是不变的, 最终导致结果出现异常。
这种情况,实际上是最好解决的了, 毕竟, 我们完全可以借助原子类这种手段, 还有别的诸如等等这些相关方式去处理。
我们或许可以把涉及数值的那个操作部分拆开来, 弄成单独的一个方法, 然后针对这个方法再实施加锁的这个动作。
大家可以启用代码范围的锁定功能, 这就意味着在规定的范围内的指令是一定不会被进行重排的, 同时它会引入锁竞争的情况, 而且把读操作和写操作都锁定了, 这样可以保证操作的原子性是得以实现的。
至于它的使用方法和之前的那些技术相比是很简洁的, 只要通过一个关键字就行了, 这个关键字可以用来锁定变量, 也可以用来锁定方法, 当然还可以用来锁住代码块。
但是, 当提及于此, 类似于我们这些如同老古董一般存在的、以Java为编程语言之程序开发人员群体, 其所产生的第一个念头便是认为此种做法会导致整个程序运行过程中之性能出现下降情况, 原因在于该锁定机制相当沉重, 以及存在诸如此类各种各样难以尽述之复杂缘由与背景情况。
那么, 事实究竟是不是像大家认为的这个样子的呢。
它会在隐式层面把内存栅栏给插入进去, 以此来禁止指令发生重排行为, 同时它还会促使代码块的运行变成串行的次序排列, 这实际上会对程序的执行性能带来一定程度的负面影响, 这样的情况在遇到多核处理器这种类型的计算机核心环境时, 表现得尤为明显。
但是, 我们不能够采取简单化的办法去把一切都否定掉, 特别是在有人使用那个重量级的锁定机制作为理由来加以否定的时候, 我们务必要保持谨慎, 进行小心地答复。
在JDK1.6之后, 情况有了很大的改变, 它的锁变成了可以升级的锁, JVM会根据实际使用的状况, 自主地来选择到底要使用什么级别的锁。
这个条件一般是在低竞争的时候才可以使用的, 还有就是不会长时间持有锁的场景。这两个场景应该覆盖了大部分的工作任务了……
在高并发的环境里, 同时在竞争状况非常激烈的这种场景下面, 我们就得去想一想是不是应该考虑采用相应的手段或技术来加以处理。
这是一个更完善的锁, 要求显示获取锁和释放锁, 让锁的控制更灵活, 还支持等待队列、中断锁和公平锁。
大家经常会问, 到底什么是公平锁。
默认的锁是非公平的, 这意味着它是允许插队的, 因此只有当一个操作出现的时候, 就会马上去尝试获取这个锁, 由于这样的操作具有很高的吞吐量, 但是可能会引发线程饥饿的情况发生, 具体表现为某些线程一直都无法获取到锁。
在使用公平锁之后, 系统就一定会按照用户发出请求的顺序来分配锁资源。这种做法虽然在性能方面比较低。但是它确实能够避免线程被饿死……
什么是中断锁?
当进行锁的获取操作的过程当中, 是有可能受到来自其他线程的中断的情况发生的, 比如说存在下面所显示出来的这样一种使用方式。
什么是等待队列?
通过创建多个等待队列这种方式, 可以带来更精细化的线程唤醒结果, 在复杂的线程协作环境中利用显式地唤醒消费者线程的操作手段能够确保在线程协作里发生应有的交互动作。
对比一下 和 。
既然目前所阐述的话题已经涉及到了锁这一方面, 那么我们就完全无法回避与之相对应的内容。
这是从Java8版本起就提供了的一种专门用来做读写的锁, 它对标的那个目标是比ck要更加的高效一些, 它就提供了像乐观锁这样的机制, 这种情况是特别适用于那种读的动作很多、写的动作很少的场景里。
什么是乐观锁?
乐观锁是一种策略, 这个策略的假设内容是多线程在执行并发读操作的时候不会发生冲突, 因此在读操作的过程之中可以不进行加锁的操作, 只是在写操作的时候会检查数据有没有被修改, 进而来选择不一样的处理方式的这种策略, 能够减少线程切换的次数, 可以提高并发的性能。
请把那两样东西拿来, 摆在一块儿仔细瞧瞧。
最后, 我们来观察一下应该如何去选定究竟是要使用哪种类型的锁具。
在这些常见的场景当中, 都存在有一些比较适合用的锁机制。但是, 这里给出的并不是唯一的那个标准, 所以在实际使用的时候, 还是希望大家都能够谨慎地去选择, 去决定到底用哪个好。
原因是并发这个环境真的是太复杂了, 每次在使用的时候都会产生出新的体会出来。所以说, 根本没有人敢说自己就完全精通多线程编程这件事儿啊。
非常高兴你能够如此有耐心地把这些内容阅读到结束, 与此同时我还希望你能够将赞给点个赞, 并且关注一下。