上一节JavaEE中我们简单介绍了一点关于CAS的内容,实际上CAS就是Compare and Swap的首拼,也是用来解决线程安全问题的,这一节我们将完整的介绍并且讲解CAS中的ABA问题以及一些解决方案.后续也讲解一些JUC涉及的内容
CAS的理解
可以一定程度上实现无锁化编程
CAS的执行流程(这里附上伪代码)
boolean CAS(address, expectValue, swapValue) { if (&address == expectedValue) { &address = swapValue; return true; } return false; }
伪代码的理解:查看内存中的值和寄存器中的值是否一致,也就是查看值是否已经被修改,如果没有被修改和期望的值一致就将需要交换的值写入内存,若已经被修改和期望的值不同则直接返回false
注:这里的伪代码只是为了方便理解,实际上的CAS操作是一条CPU指令,这个指令本身就是原子的操作.
CAS的应用
1.原子类的实现(伪代码)类似于i++操作
以下是一段基于CAS实现的伪代码,实现一个线程安全的自增操作
class AtomicInteger { private int value; public int getAndIncrement() { int oldValue = value; while ( CAS(value, oldValue, oldValue+1) != true) { oldValue = value; } return oldValue; } }
此时自增的实现就是依赖于CAS,此时使用一个寄存器来保存内存中的值,再利用while进行判断,如果CAS成功了那么就是内存中自增成功了
如果CAS失败了则去将寄存器中的值重新读取更新并重新自增,也就有效的避免的加锁来解决线程安全问题
假设我们这里使用两个线程修改一个变量(进行一次自增),但是是使用CAS的方式来修改
流程如下:
一开始两个线程都保存了内存中的值为0,无论是谁先进行自增,另一个线程都能发现其自增的时候发生的改变,然后将对应寄存器的值更新为1,接着再进行一次CAS的操作以达成两次自增的效果.
2.CAS实现自旋锁
public class SpinLock { private Thread owner = null; public void lock(){ // 通过 CAS 看当前锁是否被某个线程持有. // 如果这个锁已经被别的线程持有, 那么就⾃旋等待. // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. while(!CAS(this.owner, null, Thread.currentThread())){ } } public void unlock (){ this.owner = null; } }
3.CAS的ABA问题以及解决方案
注:以下取钱操作我们都只希望取钱一次
CAS的ABA问题其实就类似于买到了一个翻新机
类似于一个线程进去将内存中原来的值修改成100又修改回去了一样.一般来说不会出现什么问题,下面我们来考虑一些极端的情况
1.假设两个线程同时去ATM取钱(不会出现问题)
此时ATM卡了,我多摁了一次取钱操作,假设这里的取钱又是以CAS来实现了,此时就有两个线程取钱,但是无论如何也只有一个线程能取钱成功,另外一个CAS一定能察觉到账户余额的变化.
2.取钱和存钱同时进行
同样是上述场景,如果在两个CAS之间朋友又给我转了500,此时两个扣款的操作都会发生,不满足我们的预期
解决方案:
1.增加版本号
第一个1000初始值版本号为1,CAS扣款500版本号为2,第二个CAS发现是1000的时候转账后其实版本号更新为3了,一开始读取的时候版本号为1,低于当前版本,所以更新/取钱失败
2.约定数据的变化只能是单向的而不能是双向的
1.谈谈你对CAS机制的理解
CAS,全称是Compare And Swap ,就是比较的交换的意思,换句话说就是通过CPU的指令原子的比较和交换寄存器和内存中的数据.读取内存,比较是否相等,修改内存三步使用一个原子的CPU指令进行.
2.如何解决CAS机制中第二步ABA问题?
1.引入版本号,比较新旧值是否相等的时候多加上了一个版本号的保障
就像名字一样,里面放了一些多线程/并发编程使用的类,下面将介绍一些常用的类
创建线程的方法之一,可以比较优雅的获取到返回值
Runnable创建的线程关心的是执行过程,如果需要获取返回值,那么就需要重新定义一个成员变量来获取到返回值,run方法返回值是void
而Callable更关注的则是执行的结果,call方法就是线程执行得到的结果
需求:创建一个线程从1加到1000
1.使用Runnable实现
//创建一个新线程,用新的线程实现从1加到1000 public static int sum = 0; public static void main(String[] args) throws InterruptedException { Thread t = new Thread(new Runnable() { @Override public void run() { int res= 0; for (int i = 0; i <=1000 ; i++) { res += i; } sum = res; } }); t.start(); t.join(); //主线程想获取到结果就得来个成员变量来保存结果 System.out.println(sum); }
需要main线程等待执行完成后打印
2.使用Callable实现
public static void main(String[] args) throws ExecutionException, InterruptedException { //使用Callable更优雅 //期待返回值是什么类型这个泛型参数就得是什么类型 //无需引入成员变量,直接使用返回值即可 Callable
callable = new Callable () { @Override public Integer call() throws Exception { int res = 0; for (int i = 0; i <=1000 ; i++) { res += i; } return res; } }; FutureTask futureTask = new FutureTask<>(callable); Thread t = new Thread(futureTask); t.start(); //接下来的代码也不需要join,使用futuretask System.out.println(futureTask.get()); Callable接口的泛型参数就是需要的返回值类型,此时我们发现Thread类没有Callable为参数的构造方法,此时我们就找到了另一个小伙伴(FutureTask)来作为两者的粘合剂,最后打印FutureTask.get即可获取到对应的返回值.
注:这里的get操作是带有阻塞效果的,线程没有执行完毕就会进行阻塞.
也是一种锁,有人会问现在锁不就使用Synchronized就够了吗??
其实这个锁是在Synchronized还没有那么强大的时候就有了,虽然没有Synchronized用的那么频繁,但是还是有一席之地的
1.ReentrantLock是一种可重入锁
维持了传统锁的lock unlock的风格,这个风格容易忘记解锁,所以正常需要将unlock的操作放在finally里面
2.ReentrantLock相对于Synchronized的特性
2.1 提供了tryLock的操作
lock在加锁失败的时候就会陷入阻塞状态,而TryLock在加锁失败的时候直接返回false无 需阻塞
2.2 ReentrantLock提供了公平锁的实现,在构造方法中填写参数可以设置为公平锁
2.3 搭配的等待通知机制是不同的
相较于synchronized的wait/notify 这里唤醒只能随机唤醒一个线程,而ReentrantLock对应的Condition类相对来说功能更加强大,可以唤醒指定的线程
这里的Semapore就是我们操作系统中说的信号量,是迪杰斯特拉先生提出来的
注意这里的信号量有的老师可能会拿生产者消费者模式来给你举例,其实这个例子并不是完全符合,只是我们说用信号量可以实现生产者消费者模式,而不是信号量就是生产者消费者模式
;
说到信号量就不得不提到PV操作了,注意这里的两个单词是荷兰语而不是英语
P - passeren 申请 信号量 -1
V - vrijgeven 释放 信号量 +1
英文对应的释放和申请acquire 和 release
信号量也是操作系统提供的一个机制
操作系统提供了对应的api被JVM封装了一下就可以用Java代码来实现类似的操作了
信号量是更广义的锁
所谓 的锁也不过是一种特殊的信号量 可以认为是计数值为1的信号量
释放状态 信号量为0
加锁状态 信号量为1
对于这种非0即1的信号量,称为'二元信号量'
public class ThreadDemo33 { public static void main(String[] args) throws InterruptedException { Semaphore semaphore = new Semaphore(1); semaphore.acquire(); System.out.println("P操作"); semaphore.acquire(); System.out.println("P操作"); semaphore.acquire(); System.out.println("P操作"); semaphore.release(); } }
这里也就是对信号量为0之后再次申请,此时就会出现阻塞的状态,类似于锁的操作
使用Semapore解决线程安全问题,假设这里我们将二元信号量当锁来使用,完成一个两个线程各对同一个变量累加50000次
public class ThreadDemo34 { private static int count = 0; public static void main(String[] args) throws InterruptedException { Semaphore semaphore = new Semaphore(1); Thread t1 = new Thread(()->{ for (int i = 0; i < 50000; i++) { try { semaphore.acquire(); } catch (InterruptedException e) { throw new RuntimeException(e); } count++; semaphore.release(); } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 50000; i++) { try { semaphore.acquire(); } catch (InterruptedException e) { throw new RuntimeException(e); } count++; semaphore.release(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); } }
这个就类似于一个小插件,可以将下载一个大的文件交给多个线程来一起下载,每个线程负责字下载大文件中的某个部分,这样就能够提升速度,最后将所有文件拼接到一起接口
下面我们来举个例子
这里就是创建十个线程,每个线程下载任务(休眠)[1000,6000)ms,全部执行完成之后打印任务完成.
public class ThreadDemo35 { public static void main(String[] args) throws InterruptedException { //十个任务 CountDownLatch latch = new CountDownLatch(10); //创建十个线程去下载 for (int i = 0; i < 10; i++) { int id = i; Thread t = new Thread(()->{ Random random = new Random(); int time = (random.nextInt(5)+1)*1000; System.out.println("线程"+id+"开始下载"); try { Thread.sleep(time); } catch (InterruptedException e) { throw new RuntimeException(e); } System.out.println("线程"+id+"结束下载"); latch.countDown(); }); t.start(); } //await进行等待 latch.await(); System.out.println("任务完成"); } }
然后通过CountDoenLatch来感知任务的结束,开始构造方法中告知的是一共有10个任务
latch.countDown就是来告知任务结束
latch.await就是来等待所有任务全部执行完毕的
Java提供的数据结构中有线程安全的Vector,Stack,Hashtable都是线程 安全的,但是都是不建议使用的
多线程使用ArrayList
1.使用同步机制(Synchronized或者ReentrantLock)
2.Collections.synchronizedList(new ArrayList)
3.使用读写分离的思路来操作
CopyOnWriteArrayList
即写时复制的容器,当我们向容器里面添加元素的时候不是向当前容器里面添加而是先将当前容器Copy,在新的容器里面添加
优点:在读多写少的场景下避免了锁竞争
缺点:占用内存大且数据不能第一时间被读取到
多线程使用队列
1.ArrayBlockingQueue 基于数组实现的阻塞队列
2.PriorityBlockingQueue 基于堆实现的优先级阻塞队列
3.TransferQueue 最多包含一个元素的阻塞队列
4.LinkedBlockingQueue 基于链表实现的阻塞队列
多线程环境下使用哈希表
HashMap本身不是线程安全的
线程安全可以使用Hashtable 和ConcurrentHashMap
1.Hashtable只是简单的给关键方法加上了synchronized关键字而已
如果多线程访问同一个Hashtable就会造成锁冲突
一旦触发扩容机制就会出现大量拷贝,速度慢
一个Hashtable只有一把锁,只要有多个线程来访问任意数据都会出现锁竞争
2.ConcurrentHashMap
相比Hashtable来说又做了进一步的优化
1.读操作没有加锁,但是使用了volatile保证读取内存,只对写操作进行了加锁,加锁的粒度更细了,是使用"桶锁",对每个哈希桶进行加锁,降低了发生锁竞争的可能性
相对的Hashtable整体一个对象只有一把锁
2.充分使用了CAS的机制,比如size属性通过CAS来更新,避免了出现重量级锁的场景
3.优化了扩容的机制:化整为零
当发现需要扩容那么就创建一个新的数组但是每次只搬运一部分过去
搬运的期间新老数组同时存在
后续操作ConcurrentHashMap的线程都会参与搬运,每次都搬运一部分
经典面试题
1.ConcurrentHashMap的读操作是否需要加锁
答:不需要,为了减少锁的竞争,但是为了便于读取到刚修改的数据,配合了volatile关键字一起使用
2.HashMap,Hashtable,ConcurrentHashMap之间的区别
HashMap:线程不安全,key允许为null
Hashtable:线程安全,使用synchronized修饰关键方法,效率低,key不允许为空
ConcurrentHashMap:线程安全,使用synchronized锁锁对象为每个链表的头结点,锁冲突的概率较低,有效利用的CAS机制来进一步减少了锁冲突,优化了扩容方式,化整为零,key值不允许为null
3.ConcurrentHashMap在jdk128做了哪些优化?
取消了分段锁,变为给每个哈希桶分配了一把锁
实现方式由数组+链表 转换为 数组+链表+红黑树,当元素大于等于8的时候树化
1.创建线程的方式(五种)
1.使用Runnable接口
2.继承Thread类
3.基于Lambda表达式
4.Callable接口
5.线程池