常见的锁策略
- 悲观锁和乐观锁
- 悲观锁是以一种消极的态度去看待所有加锁操作,他会认为在每次访问共享资源都会出现问题,所以他会给每一段访问共享资源的代码都加上锁,也不管他是读还是写(有点像霸道总裁吃娇妻的醋,无论男的女的都要防备一手),但这样的操作势必会在锁竞争激烈的时候造成性能的负担,处理不当比如这个线程在写入数据之前先调用其他加锁的方法获取变量,这时想要写入加锁就得先获取变量完解锁,获取变量又得先结束写入数据(当然只要产生加锁都有可能导致这个问题,只是悲观锁在锁竞争激烈的情况下可能性要大一点)。
- 乐观锁与悲观锁大致相反但也不是完全不加锁,他只在提交数据的时候判断数据的提交是否被修改过(CAS算法或者版本号机制)加锁。但是由于高并发的原因在提交数据时可能会频繁的被修改(写占比非常多的情况),这会导致大量的失败和重试,就算开销少但多了起来也是非常影响性能(这个的开销也不像悲观锁那么稳定)。
总之在写操作多的情况下悲观锁要比乐观锁更合适一些,当然乐观锁的问题也是可以优化的,具体怎么选择要看实际情况。
- 重量级锁和轻量级锁
- 轻量级锁加锁解锁开销小,效率高,通常乐观锁就是轻量级锁,但也不一定,总会有意外情况。
- 重量级锁加锁解锁开销大,效率低,通常情况悲观锁是重量级锁,但和乐观锁一样,也不一定。
- 自旋锁和挂起等待锁
- 自旋锁,当一个线程遇到了锁竞争,此时他有两种方案可以选择一是一直去判断这把锁是都被释放了,释放了我就去获取,没有我就再去看,一直反复。
while (抢锁(lock) == 失败) {}
- 挂起等待锁,这就是第二种情况,当线程遇到锁竞争就选择去阻塞队列慢慢等着,等锁被释放唤醒他的时候再重新竞争,这里不是说自旋锁就不竞争了,同样需要,如果没有唤醒,要么故意为之,要么代码写错了。在某些情况下自旋锁确实比挂起等待锁能更快的获取的锁(少去了资源调度的时间)但还是那句话不一定,万一正好被切走了呢。
- 读写锁和非读写锁
- 读写锁 分为读锁和写锁在加锁时会额外的表面读写的意图也就是额外的开销,其中读锁和读锁不互斥,写锁和写锁或者和读锁都有互斥的关系。
- 可以通过这个类来点出读锁类和写锁类ReentrantReadWriteLock在其中也提供了加锁和解锁的方法。
- 读写锁在频繁读但是不频繁写的场景下使用还是非常适合的。
- 公平锁和非公平锁
- 这里的公平和非公平可能与平常的不太一样,他讲究的是先来后到而不是公平竞争
- 公平锁,在锁竞争的时候会让先来的锁先获取到被释放的锁
- 非公平锁,在锁被释放后,每个线程各凭本事来抢
- 可重入锁和不可重入锁
- 可重入锁,一个线程可以多次对一个对象进行加锁
- 不可重入锁,一个线程不能对一个对象多次加锁
CAS (Compare And Swap)
- 什么是CAS 字面意思比较并交换,设原值为V(寄存器中的值),获取到的值为A(内存中的值),修改为B。那么可以分为三个步骤,1.用V和A、进行比较,2.相等V赋值为B,不等不做任何处理3.返回操作是否成功。
- 伪代码
boolean CAS(address, expectValue, swapValue) { if (&address == expectedValue) { &address = swapValue; return true; } return false; } |
- CAS是一条指令,是原子的,由硬件提供的。
- Java标准库中有一个java.util.concurrent.atomic包里面的类都是基于这种方式实现的
AtomicInteger atomicInteger = new AtomicInteger(0); // 相当于 i++ atomicInteger.getAndIncrement(); //++i atomicInteger.decrementAndGet(); |
- ABA问题 我们可以利用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; } } |
在伪代码中我们可以看到自旋锁通过不断地判断值是否改变了,这里有一个很重要的问题,那就是值不变是否意味着就没有进行修改操作?显然不是的,它可以修改为原来的值,比如一百变为两百,两百又变回一百。表面上没发生改变实际上已经变完了,这在大多数情况下是没有太大影响的。但在极端情况下,比如取钱,当我们去ATM哪里取钱时,如果突然卡了,我们又手欠多按了几下,ATM进行了多次CAS操作,当第一次成功扣款后,第二次由于获取到的值与原值不同就不会扣款,但是如果一个人突然转了等额的钱进来,那么就会被扣了。
解决方案也是有的,我们引入版本号来看待是否被修改,让版本号只能增加不能减少,当数据被修改版本号就加一,这样当朋友转账过来后就会把版本号+1,与第二次的不匹配。
Synchronized
- 开始是乐观锁,锁竞争激烈就转换为悲观锁
- 开始是轻量级锁,锁竞争激烈转为重量级锁
- 开始自旋锁,锁竞争激烈转为挂起等待锁
- 非读写锁:读写都可以进行
- 非公平锁:当释放锁后,不按先来后到的顺序,而是大家一起去抢
- 可重入锁:同一个锁可以反复进入
Synchronized的优化手段
- 锁升级
- synchronized有四种状态,1.无锁2.偏向锁,偏向锁就是没有锁竞争就不加锁,有了立马加锁,不加锁就没有加锁的开销嘛3.轻量级锁(大概率通过自旋锁实现)4.重量级锁(自旋过久就会转换为重量级锁)
- 锁消除
当编译器认为你的锁时没有必要加的就会把代码中的锁给消除掉,就比如StringBuffer 里面就有设及到加锁操作的方法,在单线程中使用时,编译器就会自动消除掉。
- 锁粗化(锁的粒度,synchronized包含的代码越多,粗化程度越大,越少,粗化程度越小)
我们知道频繁的加锁解锁都是有开销的,就算是轻量级锁,次数多了也受不了。所以当JVM认为我们的锁可以粗化为一大段时就会帮我们粗化。
这些手段都是大佬们为了我这种菜鸡来减轻代码负担的手段,也是用心良苦了。
JUC(java.util.concurrent)中的常见类
- Callable接口
Callable与Runnable接口相比,Callable接口多了一个返回值和使用上些许不同,在使用Runnable接口时可以直接使用匿名内部类当做参数,而Callable则需要配合FutureTask类使用,在获得返回值时也有可能抛出两个异常.
2.ReentrantLock
这是一把可重入的锁,他与synchronized的用法有所不同,他加锁采用的是自己类里面的方法而不是直接写旁边,而且这两把锁是不会产生锁冲突的.同时也要注意因为ReentrantLock把加锁解锁分开了,我们要注意不要忘记解锁,可以用finally修饰一下.
那既然有了synchronized为什么还要有RenentrantLock呢?相比于synchronized,RenentrantLock
- 提供了公平锁机制,不传参数,或者传false就是非公平锁,传true就是公平锁。
- 当发生锁冲突时不会像synchronized一样采用死等,而是等一会等不到了就放弃了,当然这个时间也可以是零,无参构造器就是不等直接走.
- 有一个更为强大的通知机制,可以配合Condition唤醒指定的线程.
信号量
3.Semaphore(信号量)
用来表示可用资源的多少,可以执行P操作和V操作,其中P操作用acquire(表示申请资源,使用一个资源),V操作用release(表示释放资源,归还一个资源),当资源被释放完了还要继续申请时就会发生阻塞
当信号量只有0和1两个取值,就有点类似我们的锁了,要加锁(申请资源)就得等待另一个线程释放锁资源.
4.CountDownLatch
用来等待n个线程是否结束,n是调用构造方法时传的参数,CountDownLatch.await(),一直阻塞到n个线程全部结束,
CountDownLatch.countDown(),表示一个线程结束了。
线程安全的集合类
多线程使用ArrayList
- 自己加synchronized或者ReentrantLock给代码加锁
- Collections.synchronizedList(new ArrayList);这个类里面的关键方法都带有synchronized
- CopyOnWriteArrayList 简称COW也叫做写时拷贝,在我们对数组进行读操作的时候,什么都不做,当我们对数据进行写操作的时候就会拷贝一份新的数组,我们对新的数组进行修改修改过程中如果有读操作,就让他去读原来的数组,当我们对新数组修改完毕后就把他拷贝到旧的数组中(就是一个赋值操作,是原子的不必担心拷贝回来的时候发生拷贝到一半被读取脏数据)。这种方法很容易知道他在操作大型数组的时候很乏力只适用于小型数组,举个例子,当我们对服务器修改配置文件的时候就可以这样用,服务器热加载的时候如果有请求那就用原来的配置就好了,就不必重启服务器导致浪费。
多线程使用队列
阻塞队列里的那几种方法
1)ArrayBlockingQueue
基于数组实现的阻塞队列
2) LinkedBlockingQueue
基于链表实现的阻塞队列
3) PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
4) TransferQueue
最多只包含一个元素的阻塞队列
多线程使用哈希表
HashMap本身是不安全的,在多线程环境下我们可以使用HashTable和ConcurrentHashMap两个类
- HashTable 给关键方法加上了锁,正常锁有的特征也都有,他给整个,主要是当进行扩容的时候会有大量的数据进行一个拷贝操作,这样会很慢。
- ConcurrentHashMap
- 读操作没有加锁,写操作采用了给每个hash桶的头节点加锁,因为当我们的value不同时就算同时修改问题也不大,而同一个桶的时候由于设计到相邻的头节点的指向就会产生线程安全问题.
- 许多方法用了CAS代码效率对比HashTable更高一些
- 在进行扩容的时候,是分批次拷贝过去,当要扩容了就创立一个新的HashMap,同时移动几个元素过去,之后每次操作数组都会移动几个元素,当涉及到修改时,同时遍历两个数组。等老数组没有元素了就把他删掉了。