多线程的优点
多线程有如下优点:
- 资源利用率更好
- 程序设计在某些情况下更简单
- 程序响应更快
1.资源利用率更好
例如一个应用程序需要从本地文件系统中读取和处理文件的情景. 比方说, 从磁盘读取一个文件需要5s, 处理一个文件需要2s. 那么处理两个文件就需要:
5秒读取文件A
2秒处理文件A
5秒读取文件B
2秒处理文件B
---------------------
总共需要14秒
从磁盘读取文件的时候, 大部分CPU时间用于等待磁盘去读取数据. 在这段时间里, CPU是空闲的, 它可以做一些别的事情. 就可以利用空闲的时间去做其他事情. 如下面这样:
5秒读取文件A
5秒读取文件B + 2秒处理文件A
2秒处理文件B
---------------------
总共需要12秒
CPU 等待第一个文件被读取完。然后开始读取第二个文件。当第二文件在被读取的时候,CPU 会去处理第一个文件。记住,在等待磁盘读取文件的时候,CPU大 部分时间是空闲的。
总的说来,CPU 能够在等待 IO 的时候做一些其他的事情。这个不一定就是磁盘 IO。它也可以是网络的 IO,或者用户输入。通常情况下,网络和磁盘的 IO 比 CPU 和内存的 IO 慢的多。
2.程序设计更简单
在单线程中, 如果想编写上面这样的读取, 必须记录每个文件读取和处理的状态. 而在多线程只要启动两个线程, 每个线程去处理一个文件的读取和操作. 线程会在等待磁盘读取文件的过程中被阻塞. 在等待的时候, 其他线程能够使用CPU去处理已经读取完的数据. 这样能够带来磁盘和CPU利用率的提升.
3.程序响应更快
设想一个服务器应用, 在某一个端口监听进来的请求. 如果是单线程的, 当一个请求到来时, 它要去处理这个请求, 然后再返回去监听, 如果这个请求需要占用大量的时间来处理, 在这段时间内服务器就无法接收新的请求. 而多线程的话, 监听线程只要把请求传递给工作线程, 然后就可以立刻返回监听. 这样, 服务器显然响应更快了.
多线程的代价
从单线程应用到多线程应用并不仅仅带来好处, 也会有一些代价.
- 设计更复杂
- 上下文切换的开销
- 增加资源消耗
1.设计更复杂
虽然一些多线程应用程序比单线程的应用程序更简单, 但是一般都更复杂. 在多线程访问共享数据的时候, 代码要特别注意. 线程之间的交互往往非常复杂. 不正确的线程同步产生的错误非常难以发现, 并且难以重现以修复.
2.上下文切换的开销
当CPU从执行一个线程切换到执行另外一个线程的时候, 需要先存储当前线程的数据、指针等, 然后载入另一个线程的数据、指针等, 最后才开始执行. 这种切换称为"上下文切换". CPU会从一个上下文中执行一个线程, 然后切换到另一个上下文中执行另一个线程.
3.增加资源消耗
线程在运行时, 除了CPU, 还需要一些内存来维持它本地的堆栈, 它也需要占用操作系统中一些资源来管理线程.
可以做这样一个例子, 尝试编写一个程序, 让它创建100个线程, 这些线程什么都不做, 然后看看这个程序在运行时占用了多少内存.
并发编程模型
并发系统可以采用多种并发编程模型来实现. 并发模型指定了系统中的线程如何通过写作来完成分配给它们的任务, 不同的并发模型采用不同的方式拆分作业, 同时线程间的写作和交互方式也不相同.
1.并性工作者模型
并行工作者模型中, 传入的作业被分配到不同的工作者上, 如下图:
并性工作者模型中, 委派者(Delegator)将传入的作业分配给不同的工作者. 每个工作者完成整个任务, 工作者们并行运作在不同的线程上, 甚至可能在不同的CPU上.
在Java应用系统中, 并行工作者模型是最常见的并发模型. java.util.concurrent 包中的许多并发实用工具都是设计用于这个模型的.
并行工作者模型的优点:
容易理解. 只需要添加更多的工作者来提高系统的并行度.
并性工作者模型的缺点:
- 共享状态可能会很复杂. 在并性工作者模型中, 若存在共享资源(业务数据, 数据缓存等), 线程需要以某种方式存取共享数据, 以确保某个线程的修改对其它线程是可见的, 线程要避免死锁等其它共享资源的并发性问题, 此外, 在等待访问共享资源时, 线程之间的互相等待将会对视部分并发性.
- 无状态的工作者. 共享资源能够被系统中的其它线程修改. 所以每次都要重新读取, 以确保访问到的是最新的状态, 无法在内部保存这个状态. 每次都重读需要的数据, 将会导致速度变慢, 特别是共享资源保存在外部数据库中时
- 任务顺序是不确定的. 无法保证哪个作业最先或者最后被执行.
2.流水线模型
流水线模型如下图:
也就是, 委托者(Delegator)启动工作线程, 工作线程可以根据自己需要启动新的线程, 就像一条流水线一样.
流水线模型的优点:
- 无需共享的状态. 工作者之间无需共享状态, 意味着实现的时候无需考虑所有因并发访问共享对象而产生的并发性问题. 这使得在实现工作者的时候变得非常容易. 在实现工作者的时候就好像单个线程在处理工作.
- 有状态的工作者. 当工作者知道了没有其他线程可以修改他们的数据, 工作者可以变成有状态的(可以在内存中保存自己的数据). 有状态的工作者通常比无状态的工作者具有更高的性能
- 合理的作业顺序. 基于流水线模型实现的并发系统, 在某种程度上是有可能保证作业的顺序的.
流水线模型的缺点:
流水线模型最大的缺点是作业的执行往往分布到多个工作者上, 导致在追踪某个作业被什么代码执行时变得困难. 同时, 也加大了代码编写的难度.
如何创建并运行Java线程
Java中实现线程运行有两种方式:
- 创建 Thread 子类的一个实例, 并重写 run 方法
- 创建类实现Runnable 接口
在这就不详细介绍两种方式了
线程安全和不可变性
只有多个线程同时对一个对象执行了写操作, 线程才可能是不安全的. 多个线程同时读一个资源不会使线程不安全.
通过创建不可变的共享对象来保证对象在线程间共享时不会被修改, 从而实现线程安全.
如下图示例:
类中的 value 值是通过构造函数赋值的, 并且只允许读取操作, 这意味着 ImmutableValue 实例一旦创建, value 变量就不能在被修改, 这就是不可变性.
如果需要对 value进行操作, 可以通过 重新创建一个新的实例来实现, 如下:
方法通过创建一个新的实例, 而不直接对自己的 value 变量进行操作.
引用不是线程安全的.
即使一个对象是线程安全的, 指向这个对象的引用也可能是线程不安全的. 如下所示:
Calculator 类持有一个指向 ImmutableValue 实例的引用. 但是, 通过 setValue 和 add 方法可能会改变这个引用. 因此, 即使 Calculator 类内部使用了一个不可变对象, 但 Calculator 类本身还是可变的, 不是线程安全的.
换句话说, ImmutableValue 类是线程安全的, 但使用它的类不是.
Java内存模型
1.Java内存模型内部原理
Java内存模型把Java虚拟机内部划分为线程栈和堆, 如下图所示:
每个运行在 JVM 中的线程都拥有自己的线程栈. 这个线程栈包含了这个线程调用的方法当前执行点相关的信息. 一个线程仅能访问自己的线程栈. 即使两个线程执行同样的代码, 仍然在自己的线程栈中创建本地变量. 因此每个线程的每个本地变量都是独有的.
所有原始类型的本地变量都存放在线程栈中, 一个线程可能向另一个线程传递一个原始类型变量的拷贝, 但是不能共享这个原始类型变量本身
堆上包含在 Java 程序中创建的所有对象. 如果一个对象被创建然后赋值给一个局部变量, 或者用来作为另一个对象的成员变量, 这个对象依然是存放在堆上的.
线程中变量的存放:
- 一个本地变量如果是原始类型, 他放在线程栈上
- 一个本地变量是指向一个对象的一个引用. 引用(这个本地变量)存放在线程栈上, 但是对象本身存放在堆上
- 一个对象包含的方法中包含本地变量, 这些本地变量存放在线程栈上, 即使这些方法所属的对象存放在堆上
- 一个对象的成员变量随着这个对象自身存放在堆上, 不管这个成员变量是原始类型还是引用类型.
- 静态成员变量跟随类定义一起存放在堆上
例如如下代码:
其内存示意图如下:
如果两个线程同时执行 run()方法, 就会出现上图所示的情景. run()方法调用 methodOne()方法, methodOne()调用 methodTwo()方法.
methodOne()声明了一个原始类型的本地变量和一个引用类型的本地变量.
每个线程执行 methodOne()都会在它们对应的线程栈上创建 localVariable1 和 localVariable2 的私有拷贝. localVariable1 变量彼此完全独立
每个线程执行 methodOne()时也将会创建它们各自的 localVariable2 拷贝. 然而, 两个 localVariable2 的不同拷贝都指向堆上的同一个对象. 代码中通过一个静态变量设置 localVariable2 指向一个对象引用. 仅存在一个静态变量的一份拷贝, 这份拷贝存放在堆上. 因此, localVariable2 的两份拷贝都指向由 MySharedObject 指向的静态变量的同一个实例. MySharedObject 实例也存放在堆上. 它对应于上图中的 Object3.
注意, MySharedObject 类也包含两个成员变量. 这些成员变量随着这个对象存放在堆上. 这两个成员变量指向另外两个 Integer 对象. 这些 Integer 对象对应于上图中的 Object2 和 Object4.
methodTwo()创建一个名为 localVariable 的本地变量. 这个成员变量是一个指向一个 Integer 对象的对象引用. 这个方法设置 localVariable1 引用指向一个新的 Integer 实例. 在执行 methodTwo 方法时, localVariable1 引用将会在每个线程中存放一份拷贝. 这两个 Integer 对象实例化将会被存储堆上, 但是每次执行这个方法时, 这个方法都会创建一个新的 Integer 对象, 两个线程执行这个方法将会创建两个不同的 Integer 实例. methodTwo 方法创建的 Integer 对象对应于上图中的 Object1 和 Object5.
2.硬件内存架构
每个 CPU 都包含一系列的寄存器, 它们是 CPU 内内存的基础. CPU 在寄存器上执行操作的速度远大于在主存上执行的速度. 这是因为 CPU 访问寄存器的速度远大于主存.
每个 CPU 可能还有一个 CPU 缓存层. 实际上, 绝大多数的现代 CPU 都有一定大小的缓存层. CPU 访问缓存层的速度快于访问主存的速度, 但通常比访问内部寄存器的速度还要慢一点. 一些 CPU 还有多层缓存
一个计算机还包含一个主存. 所有的 CPU 都可以访问主存. 主存通常比 CPU 中的缓存大得多.
通常情况下, 当一个 CPU 需要读取主存时, 它会将主存的部分读到 CPU 缓存中. 它甚至可能将缓存中的部分内容读到它的内部寄存器中, 然后在寄存器中执行操作. 当 CPU 需要将结果写回到主存中去时, 它会将内部寄存器的值刷新到缓存中, 然后在某个时间点将值刷新回主存.
当 CPU 需要在缓存层存放一些东西的时候, 存放在缓存中的内容通常会被刷新回主存. CPU 缓存可以在某一时刻将数据局部写到它的内存中, 和在某一时刻局部刷新它的内存. 它不会再某一时刻读/写整个缓存. 通常. 一个或者多个缓存行可能被读到缓存, 一个或者多个缓存行可能再被刷新回主存.
3.Java内存模型和硬件内存架构之间的桥接
Java内存模型和硬件内存架构之间存在差异. 硬件内存架构没有区分线程栈和堆. 对于硬件, 所有的线程栈和堆都分布在主存中. 部分线程栈和堆可能有时候会出现在CPU缓存和CPU内部的寄存器中.
当对象和变量被存放在计算机中各种不同的内存区域中时, 就可能会出现如下问题:
- 线程对共享变量修改的可见性
- 当读、写和检查共享变量时出现 race conditions
1.共享变量的可见性
想象一下, 共享对象被初始化在主存中. 跑在CPU上的一个线程将这个共享变量读到了CPU缓存中, 然后修改了这个对象. 只要CPU缓存没有被刷新回主存, 对象修改后的版本对跑在其它CPU上的线程都是不可见的. 这样可能导致每个线程拥有这个共享对象的私有拷贝, 每个拷贝停留在不同的CPU缓存中.
解决这个问题可以使用Java中的 volatile 关键字. volatile 关键字可以保证直接从主存中读取一个变量, 如果这个变量被修改后, 总是会被写回到主存中去.
2.race conditions
想象一下, 如果县城A读取一个共享对象的变量 count 到它的CPU缓存中, 线程B也做了同样的事, 但是往一个不同的CPU缓存中. 这时两个线程都将 count 加1.
如果这些操作被顺序执行, count应该加2, 但是两次增加都没有再适当的同步下并发执行. 当线程A和线程B将 count 修改后的值写回到主存中时, 修改后的值为 原值加1, 尽管加了两次.
解决这个问题可以使用Java的同步块. 一个同步块可以保证在同一时刻仅有一个线程可以执行. 同步块还可以保证代码中所有被访问的变量将会从主存中读取, 当线程退出同步代码块时, 所有被更新的变量都会被刷新回主存中去, 不管这个变量是否被生命为 volatile.
Java同步块
Java同步块用来标记方法或者代码块是同步的, 用 synchronized 标记.
有以下四种不同的同步块:
1.实例方法同步
如下是一个同步的实例方法:
Java实例方法同步是同步在拥有该方法的对象上, 获得的锁为 该对象实例. 也就是多个线程可以在不同实例中同时执行该方法.
2.静态方法同步
如下所示:
Java静态方法同步是同步在拥有该方法的类对象上, 获得的锁为 该对象类. 也就是说多个线程只能有一个线程在执行 类的静态方法.
3.实例方法中的同步块
如下所示:
其中的 this 为同步块的锁. 一次只能由一个线程能够在同步于同一个锁对象的Java代码中执行.
4.静态方法中的同步块
如下所示:
其中的 MyClass.class 为同步块的锁.
线程通信
线程通信的目的是使线程间能够互相发送信号. 另一方面, 线程通信使线程能够等待其他线程的信号.
以下是几个线程间通信的方法:
1.通过共享对象通信
线程间发送信号的一个简单方式是在共享对象的变量里设置信号值. 也就是说多个线程拥有同一个共享实例的引用, 通过获取和设置共享实例中的变量实现线程间的通信
2.忙等待
线程在一个循环中, 不停的读取共享实例中的条件, 判断是否符合条件, 一直运行在循环中以等待这个信号.
3.wait(), notify(), notifyAll()
忙等待没有对运行等待线程的CPU进行有效的利用, 让等待线程进入睡眠或非运行状态更为明智, 直到它接收到它等待的信号.
Java中有一个内建的等待机制来允许县城在等待信号的时候变为非运行状态. Java.lang.Object类定义了三个方法 wait()、notify()和notifyAll() 来实现这个机制.
一个线程一旦调用了一个对象的wait()方法, 就会变为非运行状态, 直到另一个线程调用了同一个对象的notify()方法. 调用 wait() 和notify()方法的前提是, 线程要获得该对象的锁, 也就是线程必须在同步块中调用该方法.
但是, notify()和notifyAll()方法不会保存调用他们的方法, 当这两个方法被调用时, 有可能没有线程处于等待状态. 通知信号过后便丢弃了. 因此, 如果一个线程先于被通知线程调用wait()前调用了notify(), 等待线程将错过这个信号. 当然, 这也可能不是个问题. 不过, 在某些情况下, 这可能使等待线程永远等待, 不再醒来.
4.不要使用字符串常量作为锁
JVM编译器内部会将常量字符串转换成同一个对象, 即指向字符串常量池中的对象.
String a = "123";String b = "123";
其中a和b指向同一个对象.也就是说, 调用 b.notify()会唤醒使用 a对象作为锁的等待线程.
死锁
两个或两个以上的进程在执行过程中,因争夺资源而造成的一种相互等待的现象,若无外力作用,他们都将无法推进下去,陷入死循环.
也就是两个线程互相持有对方的锁, 而相互等待的情况
例如下面的情况:
Thread 1 locks A, waits for B
Thread 2 locks B, waits for A
当然, 死锁可能不止包含两个线程, 下面是4个线程发生死锁的情况:
Thread 1 locks A, waits for B
Thread 2 locks B, waits for C
Thread 3 locks C, waits for D
Thread 4 locks D, waits for A
避免死锁
1.加锁顺序
当多个线程需要相同的一些锁, 但是按照不同的顺序加锁, 死锁就很容易发生.
如果能确保所有的线程都是按照相同的顺序获得锁, 那么死锁就不会发生了.
如果一个线程需要一些锁, 那么它必须按照确定的顺序获取锁, 只有获得了顺序在前边的锁才能获取后边的锁(不必获取前面所有的锁, 只需要获取需要的锁)
按照顺序加锁是一种有效的死锁预防机制. 但是, 这种方式需要事先知道所有可能会用到的锁, 并对这些锁做适当的排序, 但总有些时候是无法预知的.
2.加锁时限
在尝试获取锁的时候加一个超时时间, 在尝试获取锁的过程中若超过了这个时限 该线程就放弃对锁的请求. 线程可以在获取锁超时以后主动释放之前已经获得的所有的锁, 然后等待一段时间后重试. 这段等待的时间让其他县城有机会尝试获取相同的这些锁.
使用Lock接口的 tryLock(long, TimeUnit) 方法, 可以设置获取锁的超时时间.
虽然有超时和回退, 但是如果有大量的线程竞争同一批锁, 还是会重复的死锁.
3.死锁检测
死锁检测主要针对那些不可能实现按序加锁并且加锁超时也不可行的场景.
需要一个线程和锁相关的数据结构(map, graph等), 每当一个县城获取了锁, 会在 锁相关的数据结构中将其记下.
每当一个线程请求锁失败时, 这个线程可以遍历锁的关系图查看是否有死锁发生. 当检测到死锁时, 有如下做法:
- 释放所有的锁, 回退, 并且等待一段时间后重试. 虽然有回退和等待, 但是如果有大量的线程竞争同一批锁, 还是会重复的死锁.
- 给这些线程设置优先级, 让一个(或几个)线程回退, 剩下的线程就像没发生死锁一样继续保持着他们需要的锁. 如果赋予这些线程的优先级是固定不变的, 同一批线程总是会拥有更高的优先级. 为了避免这个问题, 可以在死锁发生的时候设置随机的优先级.
饥饿和公平
一个线程因为CPU时间全部被其他线程抢走而得不到CPU运行时间, 这种状态被称之为"饥饿". 解决饥饿的方案被称之为"公平性", 即所有线程均能公平的获得运行机会.
在Java中导致线程饥饿, 有如下三个常见的原因:
- 高优先级线程吞噬所有的低优先级线程的CPU时间. 能够为每个线程设置独自的线程优先级, 优先级越高的线程获得CPU时间越多, 线程优先级设置在1-10之间
- 线程被永久阻塞在一个等待进入同步块的状态. Java的同步代码区对哪个线程允许进入的次序没有任何保障. 这就意味着理论上存在一个试图进入该同步区的线程被永久堵塞的风险, 因为其他线程总是能够持续的先于它获得访问.
- 线程在等待一个本身也处于永久等待的对象(在其上调用wait() ). 如果多个线程处在 wait() 方法上执行, 而对其调用 notify() 不会保证哪一个线程会获得唤醒, 任何线程都有可能处于继续等待的状态. 一次存在这样一个风险: 一个等待线程从来都得不到唤醒, 因为其他等待线程总是获得唤醒.
嵌套管理锁死
嵌套管理锁死类似于死锁, 例如线面是一个嵌套管理锁死的场景:
线程 1 获得 A 对象的锁。
线程 1 获得对象 B 的锁(同时持有对象 A 的锁)。
线程 1 决定等待另一个线程的信号再继续。
线程 1 调用 B.wait(),从而释放了 B 对象上的锁,但仍然持有对象 A 的锁。
线程 2 需要同时持有对象 A 和对象 B 的锁,才能向线程 1 发信号。
线程 2 无法获得对象 A 上的锁,因为对象 A 上的锁当前正被线程 1 持有。
线程 2 一直被阻塞,等待线程 1 释放对象 A 上的锁。
线程 1 一直阻塞,等待线程 2 的信号,因此,不会释放对象 A 上的锁,
而线程 2 需要对象 A 上的锁才能给线程 1 发信号……
嵌套管理锁死和死锁的不同:
死锁中, 两个线程都在等待对方释放锁. 嵌套管理锁死中, 线程1持有锁A, 同时等待线程2发来的信号, 线程2需要锁A才能发信号给线程1.
Java中的锁
锁 就像 synchronized 同步块一样, 是一种线程同步机制, 但比Java中的synchronized同步块更复杂.
为什么要有锁, 而不使用 synchronized 呢?
- 当一个代码块被 synchronized 修饰时, 一个线程获取了锁, 那么其他的线程需要等待正在使用的线程释放掉这个锁, 释放锁的方法只有两种: 一是代码执行完毕自动释放, 二是发生异常以后jvm会让线程释放锁; 如果正在执行的线程遇到什么问题, 比如等待IO等被阻塞了, 无法释放锁, 而这时候其它线程只能一直等待, 特别影响效率.
1.简单的锁实现
下面是一个 Lock类的简单实现:
当线程调用 lock() 方法, 即可阻塞等待, 通过 unlock() 唤醒线程. 其中 while(isLocked) 循环式为了防止线程没有收到notify()调用, 从wait() 中返回了, 故而进行循环验证.
2.锁的可重入性
Java 中的 synchronized 同步块是可重入的. 意思是如果一个java线程进入了代码中的synchronized 同步块, 并且因此获得了该同步对象的锁, 那么这个线程可以进入由同一个锁对象所同步的另一个Java代码块.
例如下面的例子:
其中 outer()和inner()都被生命为 synchronized, 在 outer()中调用inner()是没什么问题的, 因为他们需要的是同一把锁.
如果一个线程已经拥有了一个对象的锁, 那么它就能够访问这把锁同步的所有代码块. 这就是可重入性.
上面那个简单的锁不是可重入的. 我们对上面的Lock进行修改, 使其实现可重入:
在 while 循环中考虑到了, 如果当前调用线程即使对Lock实例进行加锁的线程, 那么while循环就不会执行, 调用 lock() 的线程就可以退出该方法.
其中的 lockedCount 用来记录同一个线程重复对一个锁对象加锁的次数. 现在这个Lock类就是可重入的了.
3.锁的公平性
Java的synchronized并不保证尝试进入线程的顺序. 因此, 如果多个线程不断竞争访问相同的synchronized同步块, 就存在一种风险-其中一个或多个线程永远也得不到访问权, 这种情况被称作县城饥饿. 为了避免这种问题, 锁需要实现公平性, 上面的两个 Lock 类内部都是用 synchronized 同步块实现的, 都没有保证公平性.
Java中的读/写锁
相比Java中的Lock实现, 读写锁更复杂一些. 即上锁的线程, 可以同时读, 不能同时写, 在写的时候也不能读.
Java.util.concurrent 包中已经包含了读写锁.
读/写锁的Java实现
下面是一个读写锁的Java实现:
信号量
Semaphore(信号量)是一个线程同步结构, 用于在线程间传递信号, 以避免出现信号丢失, 或者想锁一样保护一个关键区域.
在 java.util.concurrent 包提供了Semaphore 的官方实现, 具体实现我就不说了, 在上面基础上进行添加即可.
阻塞队列
阻塞队列和普通队列的区别在于, 当队列是空的时, 从队列中获取元素会被阻塞, 当队列是满的时, 向队列添加元素会被阻塞.
java.util.concurrent 包中提供了阻塞队列的官方实现.
下面是一个阻塞队列的简单实现:
在 enqueue 和 dequeue 方法中, 只有队列的大小等于上限或者下限时, 才调用 notifyAll()方法. 如果队列的大小即不等于上限, 也不等于下限, 任何县城调用 enqueue 或者dequeue方法时, 都不会阻塞, 正常往队列添加或移除元素.
线程池
线程池(Thread Pool)对于限制应用程序中同一时刻运行的线程数很有用. 因为每启动一个新线程都会有相应的性能开销, 每个线程都需要给栈分配一些内存等等.
我们可以把并发执行的任务传递给一个线程池, 来替代为每个并发执行的任务都启动一个新的线程. 只要池里有空闲的线程, 任务就会分配给一个线程执行. 在线程池的内部, 任务被插入一个阻塞队列(Blocking Queue), 线程池里的线程会去取这个队列里的任务. 当一个新任务插入队列时, 一个空闲线程就会成功的从队列中取出任务并且执行它.
java.util.concurrent 包中包含了线程池.
原子操作
java.util.concurrent.atomic 包中的一些类实现了原子操作.
例如下面是一个使用 AtomicBoolean 类的例子:
其中的 compareAndSet(a, b), 如果其中存储的指 与 a相等, 则以原子方式将值设置为b, 返回 true, 若不相等, 返回false, 不更新
包中还有其他原子操作的类, 可自行尝试.