上一章我们较为详细的讲解了多线程初阶的内容。这一章,我们要开始继续猛干多线程~~
首先我们要明确一点,这里的锁策略不是语法内容,而是任何关于“锁”这个话题,都会牵扯到的这个锁策略
这里的意思是站在锁发生冲突概率的预测这个角度上来看待的
悲观锁就是认为这里别人每次拿到数据都会发生修改,因此就提前加锁防止发生线程安全问题。而乐观锁则是先不加锁,如果真的发生了并发冲突问题,就会提供错误报告,让用户来解决它。
当然这俩类锁,并不是绝对的 只是相比较而言 对于锁冲突发生概率的预测来看的
这里以我的认知 就是以 其消耗的系统资源的大小来区分的
轻量级的锁 资源开销较小,效率更高
重量级的锁 资源开销较大,效率较低
这里我们用一个简单的例子 来理解这俩个锁的含义:
有个女神在校园迷倒万众男生,这时候有俩位究极舔狗,一位叫小A,一位叫小B,但是女神已经和一位高富帅谈恋爱了,这里我们可以理解为 女神这一对象 被一个线程上锁了,A这个线程也想对女神这个对象进行上锁,但是他得等啊,这时候A选择了原地转圈的方式,也就是啥都不干,不去尝试和其他女生交流发展关系,一心一意的等待女神解锁。而B呢 虽然也想要与女神进行操作,但是B知道女神已经被加锁了,但是他并没有像A一样 一直在等着女神,而是尝试与其他女生交流。以上A就是自旋锁,B就是挂起等待锁。
A消耗了大量的精力(CPU资源)一直在等待女神(等待这个对象解锁),但是只要女神失恋了,他会立刻知道,并尝试加锁
B就比较聪明了,不会一直等(节省了大量的CPU资源)去其他的事了
自旋锁是典型的轻量锁,挂起等待锁是典型的重量级锁
自旋锁的优缺点
优点:没有放弃CPU,不涉及线程阻塞和调度,它可以立刻感知到对象的解锁状态,并立即尝试进行加锁操作,效率较高
缺点:如果一直没有解锁,就会浪费大量的CPU资源(挂起等待是不会浪费CPU资源的)
互斥锁就是 只有 加锁 解锁俩个操作,一个线程加锁了 另一个线程也尝试进行加锁,只能阻塞等待。
读写锁则是有三个操作:1.针对读操作进行加锁 2.针对写操作进行加锁 3.解锁
一个线程对于数据的访问主要就是读写俩操作,如果代码中 有读操作就加读锁,如果是有写操作 就加写锁。
读加锁和读加锁之间, 不互斥.
写加锁和写加锁之间, 互斥.
读加锁和写加锁之间, 互斥.
注意, 只要是涉及到 "互斥", 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多久了.因此尽可能减少 "互斥" 的机会, 就是提高效率的重要途径
因此读写锁适用在读操作频繁的情况下~~
用上一个例子继续解读:女神和高富帅分手之后(解锁之后)这时候 A和B都有机会了 但是如果是先来后到的情况下 就按时间顺序 谁先追的女神 女神就和那个人进行加锁,这就是公平锁,反之就是非公平锁。
如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖 额外的数据结构, 来记录线程们的先后顺序
可重入锁就是如果对一个对象进行俩次加锁就不会发生阻塞等待
不可重入锁就会发生阻塞等待
1.它就是悲观锁也是乐观锁,默认是乐观 如果发现锁冲突比呼激烈的情况下,就会变成悲观锁
2.synchronized是轻量级锁,也是重量级
3.synchronized轻量级锁是自旋锁来实现的 重量级就是挂起等待锁实现的
4.不是读写锁
5.它是非公平锁
6.是可重入锁
何为CAS呢?翻译过来就是比较并交换,这里我们要注意的是,其内涵在于它是具有原子性的,只有一行的CPU指令,因此可以理解为 一定程度上帮助我们解决了线程安全问题。
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
1. 比较 A 与 V 是否相等。(比较)
2. 如果比较相等,将 B 写入 V。(交换)
3. 返回操作是否成功。
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
我们通过伪代码来理解一下CAS在实现原子类的重要作用。
class AtomicInteger {
private int value; //这里的value是固有属性 在内存里存在
public int getAndIncrement() {
int oldValue = value; //oldvalue在寄存器中存在 先把内存中的值存到寄存器
while ( CAS(value, oldValue, oldValue+1) != true) { //利用CAS来判断二者是否相等
//如果不相等的话 就更新数据 让它们相等之后在交换
oldValue = value;
}
//返回oldvalue
return oldValue;
}
}
我们可以看到 这样的方式很好的实现了原子性,一定程度上代替了锁操作。
还有一些细节问题 我们通过画图来理解:
注意:
CAS 是直接读写内存的, 而不是操作寄存器.
CAS 的读内存, 比较, 写内存操作是一条硬件指令, 是原子的
所谓的ABA问题 就是相当于 我买了一个手机 不知道这个手机 是新机还是别人翻新过的机子。
就是说CAS核心代码就是判断value和oldvalue是否相等 但是这个value值是原本的值吗,还是已经修改过的值呢?
再举一个例子来解释一下这个问题:
小明去了趟银行取钱,他的账号有1000块钱 他要取500,自助取款机正在办理的时候,ATM机用俩个线程来完成扣钱的操作。我们预期是一个线程成功了,另一个失败。但是在这线程1执行完一瞬间(还没到线程2执行的时候),小刚给小明转去了500(在线程2扣钱这个动作之前 就已经存进去了 就是 账号余额是1000)这时候,CAS开始运行,发现值(oldvalue = 1000,value = 1000)是没变化,因此就认为没扣钱 继续扣钱,就相当于扣了俩次钱。
当然这个情况发生的概率是非常小的。
当然了,解决这个问题也是非常简单的,引入版本号这个东西,让CAS来比较版本号进行修改~~
synchronized内部有几个优化机制:
在代码进入sychronized语句的时候,它会有几个阶段:
1.无锁
2.偏向锁
3.轻量级锁
4.重量级锁
进行加锁的时候,就会首先进入偏向锁状态,这个时候并不是真正的锁,相当于只是放了一个标志,如果遇到竞争的时候,才会进入下一个状态——轻量级锁,这时候synchronized通过自选的方式来进行,但是我们知道 自旋锁是非常浪费CPU资源的 因此 如果我们一直等不到 解锁操作的话,就让它变成重量级锁,也就是挂起等待了~~
轻量级锁
偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程.
如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.
重量级锁就是就是基于操作系统原生的API来进行加锁,因为是操作系统提供加锁的,因此耗费的资源是比较大的,会影响线程的速度的,如果这个锁变成了重量级锁,并且又有其他线程来竞争,那这个锁就会进入阻塞队列 调出CPU。这个时候它的效率是非常底下的~~
简单说就是 JVM给不需要加锁的地方自动给你删去了
就是该锁包含的代码多少,如果锁包含的代码越少就是说该锁的粒度 比较细~~
这个细还是粗还是看具体的应用场景的
实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.
但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁
啥是juc呢?juc就是java.util.concurrent
这里面放了很多关于并发编程的相关组件,
Callable 是一个 interface . 相当于把线程封装了一个 "返回值". 方便程序猿借助多线程的方式计算结果.
它其实是和Runnable接口一样的,只不过该接口描述任务的同时 还有一个返回值
Thread构造的时候不能直接用callable传递 需要一个辅助类
完整代码如下:
public static void main(String[] args) throws InterruptedException, ExecutionException {
Callable callable = new Callable() {
public Integer call() throws Exception {
int sum = 0;
for(int i =0;i < 1000;i++){
sum++;
}
return sum;
}
};
//FutureTask就是辅助类 进行承接的作用
FutureTask futureTask = new FutureTask<>(callable);
Thread t1 = new Thread(futureTask);
t1.start();
t1.join();
Integer res = futureTask.get();
System.out.println(res);
}
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.
ReentrantLock 也是可重入锁. "Reentrant" 这个单词的原意就是 "可重入"
基本用法就是:
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock();
try {
//工作区域
//如果需要解锁 就直接 return 有finally撑腰 进行解锁
}finally {
reentrantLock.unlock();
}
}
它提供了可设置公平锁的构造方法。
有个方法 trylock()可以设置等待时间 过了这段时间之后 自动解锁
它可以指定唤醒某个线程:
更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一
个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指
定的线程
它的锁对象就是这个实例 syn是()内
我们可以举个例子来理解一下这个概念:大商场基本上都有底下停车场,这时候我们进入一辆车的情况下,门口的标识牌显示:空余停车位 数字会减少一个,如果出去一辆车的话,就会增加一个。
这个时候 空余停车位就是信号量,信号量本质上就是计数器,它计数的是 可用资源的个数
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)
当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源
P:acquire
V:release
锁是信号量的特殊情况,信号量是锁的一般表达。
就比如说一本书在图书馆的存量是20本 ,当一个人借走就 -1:release,还回去就是+1:acquire,如果总数变成0之后,其他人还想看的话 就只能等了。
如果想要Arraylist线程安全的话有以下几个方面来实现:
自己加锁,自己使用syn和ree等
b. Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
synchronizedList 的关键操作上都带有 synchronized
c.COW 写时拷贝
如果针对arraylist读操作的情况就不加锁,如果是在写操作的时候,则拷贝一份新的数组来进行存储,如果在此操作期间,遇上了读操作,那么读的还是旧数据,读完之后 修改之后 再继续将新的替换~~
替换:就是改变一下引用~~
优点:不需要加锁竞争
缺点:修改的数据不会立刻知道,占用内存多
HashMap是不安全的,HashTable是用synchronized给关键方法进行加锁的,但是还有一个更好的方法:ConcurrentHashMap
ConcurrentHashMap的优点(优化)是啥呢?
我们知道HashTable是对整一个关键方法全部套上了synchronized,但是并不是每一次操作都会发生锁竞争,因此concurrenthashmap是把这个大锁分成了很多把小锁
这个时候如果是针对 1 2这里数据进行修改操作会发生线程安全问题的:next指向可能会出现问题。
那么这个时候是需要加锁的,也就是这俩数据如果是相邻的情况下,需要加锁操作。
但是如果不是相邻的情况下,不会发生锁冲突问题,但是HashTable确是给这个整个方法都加上了个锁,而concurrenthashmap的优点就是 只是在数组里每个头节点上加个锁,将每个头节点作为锁对象,如果俩个线程针对的是同一个锁对象 就会发生阻塞等待,如果不是一个锁对象,就无事发生。
对于读写操作比较激进,只针对写操作进行加锁,也就是说 读读之间 读写之间不会发生冲突,只有写写之间才能发生冲突。因此写操作是原子的
充分利用了CAS 削减了锁的数量 节省了大量的资源。
d.优化了扩容方式: 化整为零
发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.
扩容期间, 新老数组同时存在.
后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小
部分元素.
搬完最后一个元素再把老数组删掉.
这个期间, 插入只往新数组加.
这个期间, 查找需要同时查新数组和老数组