锁的实现者,预测接下来锁冲突(就是锁竞争,2个线程针对1个对象加锁,产生阻塞等待)的概率是大还是不大,根据这个冲突的概率决定接下来应该怎么做。
乐观锁:预测接下来冲突概率不大。
悲观锁:预测接下来冲突概率比较大。
(乐观和悲观导致最终要做的事情不一样)
通常来说,悲观锁一般要做的工作更多一些,效率更低一些;乐观锁做的工作更少一点,效率更高一点。
轻量级锁:加锁解锁过程更高效。
重量级锁:加锁解锁过程更慢,更低效。
(和乐观悲观不是同一会事,但是有一定的重合)
一个乐观锁很可能也是一个轻量级锁(不绝对)
一个悲观锁很可能也是一个重量级锁(不绝对)
自旋锁:是轻量级锁的一种典型实现。一旦锁被释放,就能第一时间拿到锁,速度更快(忙等,消耗cpu资源)。【通常纯用户态,不需要经过内核态,时间相对更短】
挂起等待锁:是重量级锁的一种典型实现。如果锁被释放,不能第一时间拿到锁,可能需要过很久才能拿到锁(好处:这个时间是空闲的,可以去学习别的技能)【通常内核的机制来实现挂起等待,时间更长了】
互斥锁:加锁就是单纯的加锁,没有更细化的区分
读写锁:能够把读和写两种加锁区分开。
synchronized是互斥锁,只有2个操作,①进了代码块加锁②出了代码块解锁。
读写锁有3个操作:①给读加锁②给写加锁【加锁操作分为了2种】③解锁;
读写锁中约定:
(看是否死锁,死锁就是没人能解锁)
可重入锁:一个锁在一个线程中连续加锁2次,不死锁
不可重入锁:一个锁在一个线程中连续加锁2次,死锁
sychronized是可重入锁,不会死锁,因为其在加锁的时候会判定一下,判定当前尝试申请锁的线程是不是已经就是锁的拥有者了,如果是直接放行。
【系统原生的锁、C++标准库的锁、Python的锁都是不可重入锁】
关于死锁的情况:
例:t1线程先对locker1加锁,再对locker2加锁;
t2线程先对locker2加锁,再对locker1加锁;
此时,死锁。
这5个哲学家
- 随机的进行吃面(拿起筷子)和思考人生(放下筷子)
- 如果他想拿筷子被别人占用了就会等待,等的过程中不会放下手里已经拿到的筷子
假设这5个哲学家,同时拿起左手边的筷子就死锁了。
①互斥使用:一个线程拿到一把锁之后,另一个线程不能使用(锁的基本特点)
②不可抢占:一个线程拿到锁,只能自己主动释放锁,不能被其他线程强行占有(不可以挖墙脚)(锁的基本特点)
③请求和保持:“吃着碗里的,惦记锅里的”(追到了1号,但是对2号跃跃欲试,也不放弃1号)(拿第2根筷子的时候不会放弃第1根筷子,请求第2根保持第1根)(代码的特点)
④循环等待:逻辑依赖是循环的(家钥匙锁车里了,车钥匙锁家里了)(代码的特点)
公平锁:遵守先来后到
非公平锁:不遵守先来后到,此处等概率竞争是不公平的,因为此处定义“先来后到”是公平的。
系统对于线程的调度是随机的,synchronized这个锁是非公平锁,要想实现公平锁需要在synchronized的基础上加上队列来记录这些加锁线程的顺序。
比较寄存器A和内存M的数据值,如果数值相同,就把寄存器B和内存M的数值进行交换。
更多时候,不关心寄存器中的数值是啥,更关心内存的数值,说是交换其实更关注内存M的内存值变换,相当于赋值操作。
CAS操作是一条CPU指令,原子的,实现了不加锁就保证线程安全。
基于CAS可以实现:
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
AtomicInteger num = new AtomicInteger(0);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
num.getAndIncrement();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
num.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(num);
}
}
运行结果:
分析:这段代码没有加锁,但是线程安全,因为有一个compareAndSwapInt操作,并非是加锁,而是CAS,此处**CAS就是在确认当前value是不是变过,如果没变过才能自增,如果变过了就先更新再自增。**没有出现加锁,因此不涉及阻塞等待,所以比之前加锁的方案快很多。
反复检查当前的锁状态,看是否解开了。
记录当前的锁被那个线程持有,如果当前owner是null,比较成功就把当前线程的引用设置到owner中,加锁成功;比较不成功,意味着owner非空,锁已经有线程持有了,此时CAS就啥都不干,直接返回false,循环继续进行,循环转的飞快,不停的尝试询问这里的锁是不是释放了。
好处:一旦锁释放,就立即能获取到。
坏处:CPU忙等。
一般乐观锁(锁冲突概率低)实现成自旋锁比较合适。
CAS关键是对比 内存和寄存器的值是否相同,通过这个对比来检测内存是不是改变过。
注意:万一对比的时候是相同的,但是不是没变过,而是a->b->a,此时就会有一定的概率出现问题。CAS只能对比值是否相同,不能确定这个值是否中间发生过改变。(大部分情况下都没事,小概率下会出bug)
问:如何解决aba问题?
答:aba关键是值会反复横跳,如果约定数据只能单方向变化(数据只能增加,或者只能减小),就可以解决。
问:如果需求要求该数值既能增加也能减小怎么办?
答:引入版本号,约定版本号只能增加,每次CAS对比的时候就不是对比数值本身,而是对比版本号。
只要约定版本号只能递增,就能保证此时不会出现aba反复横跳的问题,以版本号为基准,而不是以变量为基准了。
无锁->偏向锁->自旋锁->重量级锁
刚开始,偏向锁状态。(非必要不加锁,简单做个标记)
遇到锁竞争,就是自旋锁(轻量级锁)。
竞争更激烈,就会变成重量级锁(交给内核阻塞等待)。
编译阶段做的优化手段,检测当前代码是否多线程执行,是否有必要加锁。
如果没有必要加锁,但是把锁写了会再编译的过程中自动把锁去掉。
锁的粒度:synchronized代码块包含代码的多少,代码越多,粒度越粗,代码越少,粒度越细。
一般写代码希望锁的粒度更小一点,串行执行的代码少,并发执行的代码就多。
但是某个场景,要频繁加锁/解锁,此时编译器就可能把这个操作优化成一个更粗粒度的锁,因为每次加锁解锁都要有开销,尤其是释放锁之后,重新加锁,还需要重新竞争。
非常类似于Runnable(描述了一个任务,一个线程要干什么),描述了一个任务,一个线程要干啥,Runnable通过run方法描述,返回类型为void。但是call方法有返回值。
实现Callable可以创建线程
从1加到1000
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class ThreadDemo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 0; i <= 1000; i++) {
sum += i;
}
return sum;
}
};
//还需要找个人来完成这个任务,Thread不能直接传callable,需要包装一层
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
System.out.println(futureTask.get()); //此处的get就是获取到上述任务call方法的返回值结果
//get和join类似,都会阻塞等待
}
}
synchronized关键字是基于代码块的方式来控制加锁解锁的;ReentrantLock则是提供了lock和unlock独立的方法来进行加锁解锁,虽然大部分情况下使用synchronized就足够了,但是ReentrantLock也有单独的重要的作用。
本质上就是一个计数器,描述了当前“可用资源”的个数。
P操作,申请资源,计数器-1;
V操作,释放资源,计数器+1;
如果计数器是0,继续申请资源,就会资源等待。
典型场景:停场厂的车位剩余数量
常用的集合类ArrayList、LinkedList、HashMap、PriorityQueue是线程不安全,如果在多线程环境下使用就可能会出现问题。
解决办法:
多线程使用哈希表,HashMap肯定是不行的。
HashTable是线程安全的,也是给关键方法加锁synchronized,加到方法上相当于是给this加锁。
但是ConcurrentHashMap才是推荐方案。
1. 加锁粒度的不同:触发锁冲突的频率,HashTable是针对整个哈希表加锁,任何的增删改查操作都会触发加锁,也就都可能会有锁竞争【其实没必要把锁加的这么勤快,因为有时候不存在线程安全的问题,但是由于synchronizd是加到this上,仍然会针对同一个对象产生锁竞争,产生阻塞等待】;ConcurrentHashMap不是只有一把锁了,每个链表(头结点)作为一把锁,每次进行操作都是针对对应链表的锁进行加锁,操作不同链表就是针对不同的锁加锁,此时不会有锁冲突,导致大部分加锁操作实际上没有锁冲突。【最大区别】
上述情况是从Java8开始的,在Java1.7之前,ConcurrentHashMap使用“分段锁”,目的和上述是类似的,相当于好几个链表共用一把锁,这个设定不科学不高效,代码也复杂。
2. ConcurrentHashMap更充分利用了CAS机制,无锁编程,比如获取/更新元素个数等操作,就可以直接使用CAS完成,不必加锁。(CAS也能保证线程安全,往往比锁更高效,但是适用场景不像锁那么广泛)
3. ConcurrentHashMap优化了扩容策略,对于HashTable如果元素太多了,就会涉及到扩容,HashTable扩容需要重新申请内存空间,整体一次性搬运元素,如果元素过多,put操作就会非常卡顿;ConcurrentHashMap不会试图一次性把所有元素都搬运过去,而是每次搬运一部分,当put触发扩容,此时就会直接创建更大的内存空间,但是并不会直接把所有元素都搬运过去,而是每次只搬运一小部分,速度相当快,此时相当于同时存在2份Hash表了,此时插入元素直接往新表插入,删除元素在旧表删除,然后每次操作过程中都搬运一部分到新表过去。