目录
  1. 1. 基础概念
    1. 1.1. CPU核心数和线程数的关系
    2. 1.2. CPU时间片轮转机制
    3. 1.3. 进程和线程
    4. 1.4. 并行和并发
  2. 2. 高并发编程的意义
    1. 2.1. 多线程优势
    2. 2.2. 多线程开发注意事项
  3. 3. 认识Java线程
    1. 3.1. Java里的程序天生就是多线程的
    2. 3.2. 线程的启动与中止
      1. 3.2.1. 启动
      2. 3.2.2. 中止
    3. 3.3. 对Java里的线程再多一点点认识
      1. 3.3.1. 深入理解run() 和 start()
      2. 3.3.2. 其他的线程方法
  4. 4. 进阶Java线程
    1. 4.1. 线程的状态
    2. 4.2. 死锁
      1. 4.2.1. 概念
      2. 4.2.2. 死锁定义
      3. 4.2.3. 死锁的危害
      4. 4.2.4. 死锁的解决
    3. 4.3. 其他线程安全问题
      1. 4.3.1. 活锁
      2. 4.3.2. 线程饥饿
    4. 4.4. ThreadLocal解析
      1. 4.4.1. 与Synchronized的比较
      2. 4.4.2. Threadlocal使用
      3. 4.4.3. 实例解析
    5. 4.5. CAS基本原理
      1. 4.5.1. 什么是原子操作?如何实现原子操作?
      2. 4.5.2. CAS实现原子操作的三个问题
        1. 4.5.2.1. ABA问题
        2. 4.5.2.2. 循环时间长开销问题
        3. 4.5.2.3. 只能保证一个共享变量的原子操作
      3. 4.5.3. JDK中相关原子操作类的使用
        1. 4.5.3.1. AtomicInteger
        2. 4.5.3.2. AtomicIntegerArray
        3. 4.5.3.3. 更新引用类型
          1. 4.5.3.3.1. AtomicReference
          2. 4.5.3.3.2. AtomicStampedReference
          3. 4.5.3.3.3. AtomicMarkableReference
重拾Android-Java进阶之多线程与Android性能优化

第三天 多线程 (MultiThread)

基础概念

CPU核心数和线程数的关系

多核心: 也指单芯片多处理器( Chip Multiprocessors,简称CMP),CMP是由美国斯坦福大学提出的,其思想是将大规模并行处理器中的SMP(对称多处理器)集成到同一芯片内,各个处理器并行执行不同的进程。这种依靠多个CPU同时并行地运行程序是实现超高速计算的一个重要方向,称为并行处理。

多线程: Simultaneous Multithreading. 简称SMT.SMT可通过复制处理器上的结构状态,让同一个处理器上的多个线程同步执行并共享处理器的执行资源可最大限度地实现宽发射、乱序的超标量处理,提高处理器运算部件的利用率,缓和由于数据相关或 Cache未命中带来的访问内存延时。

核心数、线程数: 目前主流CPU有双核、三核和四核,六核也在2010年发布。增加核心数目就是为了增加线程数,因为操作系统是通过线程来执行任务的,一般情况下它们是1:1对应关系,也就是说四核CPU一般拥有四个线程。但 Intel引入超线程技术后,使核心数与线程数形成1:2的关系。

CPU时间片轮转机制

我们平时在开发的时候,并没有感受到CPU核心数限制带来的影响,想启动线程就启动线程,哪怕是在单核CPU上,为什么?这是因为CPU提供了一种CPU时间片轮转机制。

时间片轮转调度是一种最古老、最简单、最公平且使用最广的算法,又称 RR 调度。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。

百度百科对CPU时间片轮转机制原理解释如下:

  
如果在时间片结束时进程还在运行,则CPU将被剥夺并分配给另一个进程。如果进程在时间片结束前阻塞或结束,则CPU当即进行切换。调度程序所要做的就是维护一张就绪进程列表,当进程用完它的时间片后,即被移到队列的末尾。
  

时间片轮转调度中唯一有趣的一点是时间片的长度。从一个进程切换到另一个进程是需要定时间的,包括保存和装入寄存器值及内存映像,更新各种表格和队列等。假如进程切(process switch),有时称之为上下文切换(context switch),需要5ms,再假设时间片设为20ms,则在做完20ms有用的工作之后,CPU将花费5ms来进行进程切换。CPU时间的20%被浪费在了管理开销上了。

为了提高CPU效率,我们可以将时间片设为5000ms,这时浪费的时间只有0.1%。但考虑到在一个分时系统中,如果有10个交互用户几乎同时按下回车键,将发生什么情况?假设所有其他进程都用足他们的时间片的话,最后一个不幸的进程不得不等待5s才获得运行机会,多数用户无法忍受一条简短命令要5s才做出响应,同样的问题在一台支持多道程序的个人计算机上也会发生。

结论可以归结如下: 时间片设的太短会导致过多的进程切换,降低了CPU效率;而设的太长又可能引起对短的交互请求的响应变差。将时间片设为100ms通常是一个比较合理的折中。

在CPU死机的情况下,其实不难发现,当运行一个程序的时候,把CPUCPU给弄到了100%在不重启电脑的情况下,其实我们还是还是有机会把它Kill掉。

进程和线程

进程是程序运行资源分配的最小单位
进程是操作系统进行资源分配的最小单位,其中资源包括:CPU、内存空间、磁盘等等,同一进程中的多条线程共享该进程中的全部系统资源,而进程和进程之间是相互独立的。进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。

进程是程序在计算机上的一会次执行活动,当你运行一个程序,也就你启动了一个进程。显然,程序是死的、静态的,进程是活的、动态的。进程可以分为系统进程和用户进程。凡是用于完成操作系统的各种功能的进程就是系统进程,它们就是处于运行状态下的操作系统本身,用户进程就是所有由用户启动的进程。

线程是CPU调度的最小单位,且必须依赖于进程而存在
线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的、能独立运行的基本单位,线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。

线程无处不在
任何一个程序都必须要创建线程,特别是Java,不管任何程序都必须启动一个main函数的主线程;Java Web开发里面的定时任务、定时器、JSP和Servlet、异步消息处理机制、远程访问接口 RM 等,任何一个监听事件,onClick的触发事件等都离不开线程和并发的知识。

并行和并发

举个例子

如果有条高速公路A上面并排有8条车道,那么最大的并行车辆就是8辆此条高速公路A同时并排行走的车辆小于等于8辆的时候,车辆就可以并行运行。CPU也是这个原理,一个CPU相当于一个高速公路A,核心数或者线程数就相当于并排可以通行的车道;而多个CPU就相当于并排有多条高速公路,而每个高速公路并排有多个车道。

当谈论并发的时候一定要加个单位时间,也就是说单位时间内并发量是多少?离开了单位时间其实是没有意义的。

俗话说,一心不能二用,这对计算机也一样,原则上一个CPU只能分配给一个进程,以便运行这个进程。我们通常使用的计算机中只有一个CPU,也就是说只有一颗心,要让它一心多用同时运行多个进程,就必须使用并发技术。实现并发技术相当复杂,最容易理解的是“时间片轮转进程调度算法”。

总结如下

  • 并发: 指应用能够交替执行不同的任务,比如单CPU核心下执行多线程并非是同时执行多个任务,如果开启两个线程执行,其实就是在以我们几乎毫无察觉到的速度不断去切换这两个任务,以达到“同时执行效果”。

  • 并行: 指应用能够同时执行不同的任务,比如,吃饭的时候可以边吃饭边打电话看电视,这几件事都可以同时进行。

两者区别:一个是交替执行,一个是同时执行。

高并发编程的意义

由于多核多线程的CPU的诞生,多线程、高并发的编程越来越重要,多线程可以给程序性能处理带来质的提升:

多线程优势

  • 充分利用CPU的资源

从上面的CPU的介绍,可以看的出来,现在市面上没有CPU的内核不使用多线程并发机制的,特别是服务器还不止一个CPU,如果还是使用单线程的技术做思路,明显就out了。因为程序的基本调度单元是线程,并且一个线程也只能在一个CPU的一个核的一个线程跑,如果你是个i3的CPU的话,最差也是双核心4线程的运算能力:如果是一个线程的程序的话,那是要浪费3/4的CPU性能:如果设计一个多线程的程序的话,那它就可以同时在多个CPU的多个核的多个线程上跑,可以充分地利用CPU,减少CPU的空闲时间,发挥它的运算能力,提高并发量。

  • 加快响应用户的时间

比如我们经常用的迅雷下载,都喜欢多开几个线程去下载,谁都不愿意用一个线程去下载,为什么呢?答案很简单,就是多个线程下载快啊。
我们在做程序开发的时候更应该如此,特别是我们做互联网项目,网页的响应时间若提升1s,如果流量大的话,就能增加不少转换量。做过高性能web前端调优的都知道,要将静态资源地址用两三个子域名去加载,为什么?因为每多一个子域名,浏览器在加载你的页面的时候就会多开几个线程去加载你的页面资源,提升网站的响应速度。多线程,高并发真的是无处不在。

  • 可以使代码模块化、异步化、简单化

例如我们在做 Android程序开发的时候,主线程的UI展示部分是一块主代码程序部分,但是UI上的按钮用相应事件的处理程序就可以做个单独的模块程序拿出来。这样既增加了异步的操,又使程序模块化,清晰化和简单化。
时下最流行的异步程序处理机制,正是多线程、并发程序最好的应用例子。

多线程开发注意事项

  • 线程之间的安全性

在同一个进程里面的多线程是资源共享的,也就是都可以访问同一个内存地址当中的一个变量。例如:若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的:若有多个线程同时执行写操作,一般都需要考虑线程同步,否则就可能影响线程安全。

  • 线程之间的死循环

为了解决线程之间的安全性引入了Java的锁机制,而一不小心就会产生Java线程死锁的多线程问题,因为不同的线程都在等待那些根本不可能被释放的锁,从而导致所有的工作都无法完成。假设有两个线程,分别代表两个饥饿的人,他们必须共享刀叉并轮流吃饭。他们都需要获得两个锁:共享刀和共享叉的锁。
假如线程A获得了刀,而线程B获得了叉。线程A就会进入阻塞状态来等待获得叉,而线程B则阻塞来等待线程A所拥有的刀。这只是人为设计的例子,但尽管在运行时很难探测到,这类情况却时常发生.

  • 过多线程导致耗尽服务器资源从而引起死机当机问题

线程数太多有可能造成系统创建大量线程而导致消耗完系统内存以及CPU的“过渡切换”,造成系统的死机,

那么我们该如何解决这类问题呢?

某些系统资源是有限的,如文件描述符。多线程程序可能耗尽资源,因为每个线程都可能希望有一个这样的资源。如果线程数相当大,或者某个资源的侯选线程数远远超过了可用的资源数则最好使用资源池。一个最好的示例是数据库连接池。只要线程需要使用一个数据库连接,它就从池中取出一个,使用以后再将它返回池中。

认识Java线程

Java里的程序天生就是多线程的

一个Java程序从main()方法开始执行,然后按照既定的代码逻辑执行,看似没有其他线程参与,但实际上,Java程序天生就是多线程程序,因为执行Main()方法的是一个名称为 main 的线程。

【1】main //main 线程,用户程序入口

【2】Reference Handler //清除 Reference 线程

【3】Finalizer //调用对象的 finalizer 方法的线程

【4】Signal Dispatcher //分发处理发送给 JVM 信号的线程

【5】Attach Listener //内存dump,线程dump,类信息统计,获取系统属性等

【6】Monitor Ctrl-Break //监控Ctrl-Break中断信号

线程的启动与中止

启动

启动线程的方式有(但其实真正意义上只有前面两种):
1、X extends Thread;,然后X.run
2、X implements Runnable;然后交给Thread运行
3、X implements Callable;然后交给Thread运行

Callable、Future和FutureTask

Runnable是一个接口,在它里面只声明了一个run()方法,由于run()方法返回值为void类型,所以在执行完任务之后无法返回任何结果。

Callable位于java.util.concurrent包下,它也是一个接口,在它里面也只声明了一个方法,只不过这个方法叫做call(),这是一个泛型接口,call()函数返回的类型就是传递进来的V类型。

Future就是对于具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。

图片1.png

因为Future只是一个接口,所以是无法直接用来创建对象使用的,因此就有了下面的FutureTask。

图片2.png

图片3.png

FutureTask类实现了RunnableFuture接口,RunnableFuture继承了Runnable接口和Future接口,而FutureTask实现了RunnableFuture接口。

所以它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值。
事实上,FutureTask是Future接口的一个唯一实现类。

要new一个FutureTask的实例,有两种方法

图片4.png

中止

线程自然中止,要么是run执行完成,要么是抛出未处理的异常导致线程提前结束。

手动中止
暂停、恢复和停止操作对应在线程 Thread 的 API 就是 suspend()、resume() 和 stop()。但是这些API是过期的,也就是不建议使用的。

不建议使用的原因有:以suspend()方法为例,在调用后,线程不会释放已经占有的资源(比如锁),而是占有着资源进入睡眠状态,这样非常容易引发死锁。同样,stop()方法在终结一个线程时不会保证线程的资源被释放,通常是没有给予线程完成资源释放的工作机会,因此会导致程序可能工作在不确定的状态下。正因为 suspend()、resume() 和 stop() 方法带来的副作用,所以这些方法才会被标注不建议使用的过期方法。

安全的中止则是其他线程通过调用某个线程A的 interrupt() 方法对其进行中断操作,中断好比其他线程对该线程打了个招呼,“A,你要中断了”,但这并不代表线程A会立即停止自己的工作,同样的A线程完全可以不理会这种中断请求。因为Java里的线程是协作式的,不是抢占式的。线程通过检查自身中断标志位是否被置为true来进行响应,线程通过方法 isInterrupted() 来进行判断是否被中断,也可以调用静态方法 Thread.interrupted() 来进行判断当前线程是否被中断,不过 Thread.interrupted() 会同时将中断标识位改写成false。

如果一个线程处于阻塞状态(线程通过调用 thread.sleep、thread.join、thread.wait …),则在线程在检查自己的中断标识位为true时,则会在这些阻塞方法调用处抛 InterruptedException 异常,并且在抛出异常后会立即将线程的中断标识位清除,即重新设置为false。

同时,不建议自定义一个取消标识位来中止线程的运行,因为 run() 方法里有阻塞调用时,是不会立刻检测到取消标识的,线程必须从阻塞调用返回后,才会检查这个取消标识。这种情况下,使用中断会更好。因为:
一、一般的阻塞方法,如 sleep 等本身就支持中断的检查;
二、检查中断位的状态和检查取消标识位的状态没什么区别,用中断位的状态还可以避免声明取消标识位,减少资源的消耗。

注意:处于死锁状态的线程无法被中断

对Java里的线程再多一点点认识

深入理解run() 和 start()

Thread类是Java里面对线程概念的抽象,可以这样理解:我们通过 new Thread() 其实只是 new 出一个 Thread 的实例,还没有和操作系统中真正意义上的线程挂起钩来,只有执行了 start() 方法后,才实现了真正意义上的启动线程。

start() 方法让一个线程进入就绪队列等待分配CPU,分到CPU后才调用实现的 run() 方法,start() 方法不能重复调用。

run() 方法是业务逻辑实现的地方,本质上和任意一个类的任意一个成员方法并没有任何区别,可以重复执行,可以被单独调用。

其他的线程方法

yield() 方法:使当前线程让出CPU占有权,但让出的时间是不可设定的,也不会释放资源,所有执行 yield() 的线程有可能在进行可执行状态后马上又被执行了。

join() 方法:把指定的线程加入到当前的线程,可以将两个交替执行的线程合并为顺序执行的线程,比如在线程B中调用了线程A的 join() 方法,则直到线程A执行完毕后,才会继续执行线程B。

wait() 方法:

notify() 方法:

notifyAll() 方法:

进阶Java线程

线程的状态

Java中线程的状态分为6种:

1、初始(NEW):新创建一个线程对象,但还没有调用 start() 方法。

2、运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态统称为“运行”。

线程对象创建后,其他线程(比如main线程)调用了该对象的 start() 方法,该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)

3、阻塞(BLOCKED):表示线程阻塞于锁

4、等待(WAITING):进入该状态的线程需要等待其他线程做出一些动作(通知或中断)

5、超时等待(TIMED_WAITING):该状态不同于 WAITING,它可以在指定的事件后自行返回线程队列

6、终止(TERMINATED):表示该线程已经执行完毕

状态之间的变迁借鉴享学老师绘制的流程概念图:

图片1.png

死锁

概念

是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成一种阻塞线程,若无外力作用,它们都将无法推进下去,此时称系统处于死锁状态或者系统产生了死锁。

举个例子:A和B去按摩洗脚,都想在洗脚的时候,同时顺便做个头部按摩,18号技师擅长足底按摩,38号技师擅长头部按摩。
这个时候A先抢到38,B先抢到18,两个人都想同时洗脚和头部按摩,于是就互不相让,扬言说死也不相让,这样的话,A抢到38要18,B抢到18要38,在这种情况下,A和B就产生了死锁,那么怎么解决这个问题呢?

第一种:假如这个时候,来个10号,刚好也是擅长头部按摩的,A有了38,自然就归B所有,这个时候死锁就被打破了,不存在了。
第二种:C出场了,用霸道总裁的手段强迫A和B,必须先洗脚,再头部按摩,这种情况下,A和B谁先抢到18,谁就可以进行下去,另外一个没抢到的,就只等干等着,这种情形下,也不会产生死锁。

总结:死锁是必然发生在多操作者(M>=2)情况下,争夺多个资源(N>=2个,且N<=M)才会发生这种情况。很明显,单线程自然不会有死锁,同时,死锁还有几个要求:1、争夺资源的顺序不对,如果争夺资源的顺序是一样的,也不会产生死锁;2、争夺者拿到资源不肯释放。

死锁定义

死锁的发生必须具备以下四个必要条件:

  • 互斥条件: 指进程对所分配到的资源进行排他性使用,即在某一段时间内某资源只由一个进程占用,如果此时还有其他进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放;
  • 请求和保持条件: 指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程阻塞,但又对自己已获得的其他资源保持不放;
  • 不剥夺条件: 指进程已获得资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
  • 环路等待条件: 指在发生死锁时,必然存在一个进程 - 资源的环形链,即进程集合 {$P_0$, $P_1$, $P_2$, … ,$P_n$}中的 $P_0$ 正在等待一个 $P_1$ 占用的资源; $P_1$ 正在等待 $P_2$ 占用的资源;…

理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能的避免、预防和解除死锁。
因此,只需打破这个四个必要条件之一,就可以有效预防死锁的发生:

  • 打破互斥条件: 改造独占性资源为虚拟资源,大部分资源已无法改造;
  • 打破不可抢占条件: 当一进程占有一独占性资源后又申请一独占性资源而无法满足,则推出原占有的资源;
  • 打破占有且申请条件: 采用资源预先分配策略,即进程运行前申请全部资源,满足则运行,不然就等待,这样就不会占有且申请;
  • 打破循环等待条件: 实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按序号递增的形式申请资源。

避免死锁的常见算法有:有序资源分配法银行家算法

死锁的危害

1、线程不工作了,但是整个程序还是活着的
2、没有任何的异常信息可供检查
3、一旦程序发生了死锁,是没有任何办法恢复的,除非重启应用程序,对正式发布的程序来说,这很致命。

死锁的解决

关键是保证拿锁的顺序一致

两种解决方式:
1、内部通过顺序比较,确定拿锁的顺序;
2、采用尝试拿锁的机制。

其他线程安全问题

活锁

两个线程在尝试拿锁的机制中,发生多个线程之间互相谦让,不断发生同一个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到,而将本来已经持有的锁释放的过程。

解决办法:每个线程休眠随机数,错开拿锁的时间。

线程饥饿

低优先级的线程,总是拿不到执行时间。

ThreadLocal解析

与Synchronized的比较

ThreadLocal 和 Synchronized 都用于解决多线程并发访问。可是 ThreadLocal 与 Synchronized 有本质的差别。
Synchronized 是利用锁的机制,使变量或代码块在某一时刻仅仅能被一个线程访问。
而 ThreadLocal 为每个线程都提供了变量的副本,使得每个线程在某一时间访问到的并非同一个对象,这样就更隔离了多个线程对数据的数据共享。

Threadlocal使用

ThreadLocal类接口提供4个方法,分别是:

  • void set(Object value)
    设置当前线程的线程局部变量值

  • public Object get()
    该方法返回当前线程所对应的线程局部变量

  • public void remove()
    将当前线程局部变量的值删除,目的是为了减少内存的占用,该方法是JDK 5.0新增的方法,需要指出的是,当线程结束后,对应该线程的局部变量将自动被垃圾回收,所以显示调用该方法清除线程的局部变量并不是必须的操作,但它可以加快内存的回收速度。

  • protected Object initialValue()
    返回该线程局部变量的初始值,该方法是一个protected的方法,显然是为了让子类覆盖而设计的。这个方法是一个延迟调用的方法,在线程第一次调用get()或set(Object)时才执行,并且仅执行一次,ThreadLocal中的缺省实现直接返回一个null。

public final static ThreadLocal<String> RESOURCE = new ThreadLocal<String>;
//RESOURCE 代表一个能够存放String类型的 ThreadLocal对象,此时不论什么的一个线程能够并发访问这个变量,对它进行写入、读取操作,都是线程安全的。

实例解析

图片7.png

图片8.png

图片9.png

图片10.png

图片11.png

上面先取到当前线程,然后调用 getMap() 方法对应的 ThreadLocalMap,ThreadLocalMap 是 ThreadLocal 的静态内部类,然后 Thread 类中有一个这样的类型成员,所以 getMap 是直接返回 Thread 的成员。

看一下 ThreadLocal 的内部类 ThreadLocalMap 源码:

图片12.png

可以看到有个Entry内部静态类,它继承了WeakReference,总之它记录了两个信息,一个是ThreadLocal<?>类型,一个是Object类型的值。getEntry()则是获取某个ThreadLocal对应的值,set方法就是更新或者赋值相应的ThreadLocal对应的值。

图片13.png

图片1.png

回顾我们的get方法,其实就是拿到每个线程独有的 ThreadLocalMap
然后再用 ThreadLocal 的当前实例,拿到Map 中的相应的 Entry,然后就可以拿到相应的值返回回来。当然,如果Map为空,还会先进行map的创建,初始化等工作。

CAS基本原理

什么是原子操作?如何实现原子操作?

假定有两个操作 A 和 B(A和B可能都很复杂),如果从执行A的线程来看,当另一个线程执行B时,要么B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。

实现原子操作可以使用锁,锁机制,满足基本的需求是没问题的,但是有时我们需求并非那么简单,我们需要更有效,更加灵活的机制,synchronized关键字是基于阻塞的锁机制,也就是说当一个线程拥有锁的时候,访问同一资源的其他线程需要等待,直到该线程释放锁。

这里会有问题:首先,如果被阻塞的线程优先级很高很重要怎么办?其次,如果获得锁的线程一致不释放锁怎么办?(这种情况是很糟糕的)。还有一种情况,如果有大量的线程来竞争资源,那么CPU将会花费大量的时间和资源来处理这些竞争,同时,还有可能出现一些例如死锁之类的情况。最后,其实锁机制是一种比较粗糙、粒度比较大的机制,相对于像计数器这样的需求有点儿过于笨重。

实现原子操作还可以使用当前的处理器都支持的CAS(Compare and Swap)的指令,只不过每个厂家实现的算法不一样,每一个CAS操作过程都包含三个运算符:一个内存地址V,一个期望的值A,和一个新值BB,操作的时候如果这个地址上存放的值等于这个期望的值A,则将地址上的值赋为新值B,否则不做任何操作。

CAS的基本思路就是,如果这个地址上的值和期望的值相等,则给其赋予新值,否则不做任何事,但是要返回原值多少。循环CAS就是在一个循环里,不断的做cas操作,直到成功为止。

图片2.png

CAS实现原子操作的三个问题

ABA问题

因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上是变化了。

ABA问题的解决思路就是使用版本号,在变量前面追加版本号,每次变量更新的时候把版本号加上1,那么A->B->A就变成了1A->2B->3A.

举个通俗点的例子,你倒了一杯水放桌子上,干了点别的事,然后同事把你水喝了又给你重新倒了一杯水,你回来看水还在,拿起来就喝,如果你不管水中间被人喝过,只关心水还在,这就是ABA问题。

如果你是一个讲卫生讲文明的小伙子,不但关心水在不在,还要在你离开的时候水被人动过没有,因为你是程序员,所以就想起了放了张纸在旁边,写上初始值0,别人喝水前麻烦先做个累加才能喝水。

循环时间长开销问题

自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。

只能保证一个共享变量的原子操作

当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以考虑使用

当然还有一个取巧的方法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量 i=2,j=a,合并一下 ij=2a,然后用CAS来操作ij,从Java 1.5开始,JDK提供了 AtomicReferecnce类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。

JDK中相关原子操作类的使用

AtomicInteger

  • int addAndGet(int delta):以原子方式将输入的数值与实例中的值(AtomicInteger里的value)相加,并返回结果。

  • boolean compareAndSet(int expect,int update):如果输入的数值等于预期值,则以原子方式将该值设置为输入的值。

  • int getAndIncrement():以原子方式将当前值加1,注意,这里返回的是自增前的值。

  • int getAndSet(int newValue):以原子方式设置为newValue的值,并返回旧值。

AtomicIntegerArray

主要是提供原子的方式更新数组里的整型。其常用方法如下:

  • int addAndGet(int i,int delta):以原子方式将输入值与数组中索引i的元素相加。

  • boolean compareAndSet(int i,int expect,int update):如果当前值等于预期值,则以原子方式将数组位置i的元素设置成update值。

需要注意的是,数组value通过构造方法传递进去,然后AtomicIntegerArray会将当前数组复制一份,所以当AtomicIntegerArray对内部的数组元素进行修改时,不会影响传入的数组。

更新引用类型

原子更新基本类型的AtomicInteger,只能更新一个变量,如果需要原子更新多个变量,就需要使用这个原子更新引用类型提供的类。Atomic包提供了以下3个类:

AtomicReference

原子更新引用类型

AtomicStampedReference

利用版本戳的形式记录了每次改变后的版本,这样的话就不会存在ABA问题了。

这就是AtomicStampedReference的解决方案:AtomicMarkableReference 跟 AtomicStampedReference 差不多,AtomicStampedReference 是使用pair的int stamp作为计数器使用,AtomicMarkableReference 的pair 使用的是boolean mark。

还是那个水的例子,AtomicStampedReference可能关心的是动过几次,AtomicMarkableReference关心的是有没有被人动过,方法都比较简单。

AtomicMarkableReference

原子更新带有标记位的引用类型。可以原子更新一个布尔类型的标记位和引用类型。构造方法是AtomicMarkableReference(V initialRef,booleaninitialMark)。

打赏
  • 微信
  • 支付宝

评论