目录
HashTable和ConcurrentHashMap的区别(面试题)
常见的锁策略
一、乐观锁和悲观锁
锁的实现者预测接下来锁冲突(锁竞争)的概率是大还是不大,根据冲突的概率,决定接下来该怎么做。
乐观锁:预测接下来的冲突概率不大
悲观锁:预测接下来的冲突概率比较大。
两者因为预测的冲突概率不同,导致最终做的工作就不一样。
悲观锁一般要做的工作更多一些,效率就会更低,一般来说乐观锁做的工作会少一些,效率更高。
二、轻量级锁和重量级锁
轻量级锁:加锁解锁,过程更快更高效
1.少量的用户态和内核态切换;2.很容易引发线程的调度
重量级锁:解锁解锁,过程更慢,更低效。
1.大量的内核态用户态切换;2.很容易引发新线程调度
与乐观锁和悲观锁有一定的重合,
一个乐观锁可能是一个轻量级锁(不绝对)
一个悲观锁可能也是一个重量级锁(不绝对)
对于轻量级锁,锁冲突的概率比较大,就会转成重量级锁。轻量级锁(自旋锁)吃的cpu比较多,重量级锁会阻塞等待,不吃cpu但是会花费调度时间。
三、自旋锁和挂起等待锁
自旋锁是轻量级锁的一种典型实现;
挂起等待锁是重量级锁的一种典型实现。
情景:和女神表白,被发好人卡,加锁失败,
自旋锁:加锁失败之后,每天仍然锲而不舍,像女神问候,等女神和男朋友分手,机会来了,就加锁。
优点:纯用户态,不涉及内核态。一旦锁被释放,就能第一时间拿到锁,速度会很快。
缺点:如果锁被其他线程持有的时间比较久,那么就会持续消耗CPU的资源(挂起等待锁不会)
挂起等待锁:先去做别的事不管女神,等女神分手,然后主动来找自己,再加锁。
缺点:涉及内核态,等待锁的时间更长
关于synchronized,既是悲观锁也是乐观锁,即使轻量级锁,也是重量级锁,轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待所实现。
synchronized会根据当前锁竞争的激烈程度,自适应锁策略。
如果锁冲突不激烈,以轻量级锁/乐观锁的状态运行
如果锁冲突激烈,以重量级锁/悲观锁的状态运行。
四、互斥锁和读写锁
synchronized是互斥锁:只加锁,进入代码块加锁,出了代码块,解锁。
读写锁:能够把读和写区分开,1.给读加锁;2.给写加锁;3.解锁
读写锁中,约定:
1.读锁和读锁之间不会有锁竞争,不会产生阻塞等待(不会影响程序的速度,代码还是跑的很快)
2.写锁和写锁之间,有锁竞争(减慢速度,但是保证准确性)
3.读锁和写锁之间,也有锁竞争(减慢速度,但是保证准确性)
读写锁更适用于一写多读的情况。
五、可重入锁和不可重入锁
如果一个锁,在一个线程中,连续对该锁加锁两次,不死锁,就叫可重入锁,如果死锁了,就叫不可重入锁。
比如下面的代码,第二次尝试给locker加锁,就需要等待第一个锁释放,第一个锁释放,就需要等待第二个锁加锁成功(也就是相当于一种情况,车钥匙掉家里了,家里的钥匙掉在扯里了),从逻辑上是矛盾的,此时也就是死锁了,所以是一个不可重入锁。
关于死锁(面试考)
死锁就是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。也就是没有人来解锁。
当多个线程同时被阻塞,他们中的一个或者全部都在等待某个资源被释放。由于线程被巫启贤的阻塞,因此程序就不可能正常终止。
“哲学家吃面条问题”就会产生死锁问题:
有几个哲学家,围着中间有一碗面条的桌子,每个哲学家两两之间放着一根筷子
假如有以上这六个哲学家,那么他们想要吃到面条,就必须拿到筷子(先拿到左边再拿到右边),此时可以看见,不是每个哲学家都能拿到筷子,当哲学家发现筷子拿不起来了(被别人占用了),就会阻塞等待。
但是此时出现以下情况:
每个哲学家都拿起自己左手边的筷子,然后尝试拿起右手边的筷子,就会发现右手边的筷子被占用了,由于哲学家们互不相让,就会导致死锁。
关于会产生死锁的情况:
1.一个线程,一把锁,可重入锁没事,不可重入锁死锁;
2.两个线程两把锁,即使是可重入锁,也会死锁;
当两个对象都有对应的锁,然后这两个对象尝试获取对方的锁,此时就会发生死锁,比如以下代码:
对于t1和t2,t1先对locker1加锁,t2先对locker2加锁,
然后后面t1和t2分别对对方的锁加锁(t1对locker2加锁,t2对locker2加锁),此时就会发生死锁。
3.N个线程,M把锁
线程数量和锁数量更多了,就更容易死锁,哲学家就餐问题就是因为这个原因产生死锁。
关于死锁的四个必要条件:
1.互斥使用,一个线程拿到一把锁之后,另一个线程不能使用;
2.不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能被资源占有者主动释放;
3.请求和保持,“吃着碗里,看着锅里”,当资源请求者在请求其他的资源时同时保持对原有资源的占有;
4.循环等待,逻辑依赖循环,“钥匙锁车里,车钥匙锁家里”,就是存在一个等待队列:P1占有P2的,P2占有P3的,P3占有P1的......相互依赖形成一个死循环。
上述的四个必要条件,只要有一个被解决就不会出现死锁。
避免出现死锁:破解循环等待,针对锁进行编号,如果需要同时获取多把锁,无比是先对编号小的加锁,然后对编号大的加锁。
比如前面出现的情况:
想要解决这个问题,可以将对象调换:
约定好先获取lock1,再获取lock2,就不会环路等待。
六、公平锁和非公平锁
假如有A,B,C,
公平锁:遵守“先来后到”,B比C先来的,当A释放锁之后,A就能线于C获取锁
非公平锁:不遵守“先来后到”,B和C都有可能获取到锁
synchronized默认情况下是一个非公平锁。
关于synchronized,既是悲观锁也是乐观锁,即是轻量级锁,也是重量级锁,轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待所实现,不是读写锁,是可重入锁,是非公平锁。
CAS(compare and swap)
CAS就是一条指令,主要作用如下:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
1. 比较 A 与 V 是否相等(比较)
2. 如果比较相等,将 B 写入 V(交换)
3. 返回操作是否成功
也就是比较内存A和寄存器V中的值,如果数值相同,就把内存B和寄存器V中的值进行交换。
更多时候,我们不关心寄存器中的值,更关心内存的数值,这种操作相当于赋值操作。
对应的伪代码如下:
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
对于上述伪代码,不是原子操作,可能会引起线程安全问题,但是CAS是一个单条的指令,是符合原子性的,不存在线程安全问题。
CAS的作用
1.实现原子类
标准库中提供了java.util.concurrent.atomic包,里面的类都基于这种方式来实现的,
比如AtominclInteger类,就是一个原子类,其中的getAndIncrement就相当于i++操作:
AtomicInteger num = new AtomicInteger();
num.getAndIncrement();
伪代码实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
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;
}
}
CAS的ABA问题(面试常考)
CAS关键是对比内存和寄存器的值是否相同(就是通过这个对比,来检测内存是不是变过)
万一对比的时候是相同的,但是不是没变过,而是从a -> b -> a(值是相同的,但是中间可能变过,不是原来的a了),此时,有一定概率就会出问题(类比翻新机)
CAS只能对比值是否相同,不能确定这个值是否中线发生过改变,大概率上是不会出现问题的。
如果约定数据只能单方向变化,就能解决此问题:
如果需求要求该数值,既能增加也能减小,可以引入另一个版本号变量,约定版本号只能增加,这样每次CAS对比的时候,就不是对比数值本身,而是对比版本号,每次修改都会增加一个版本号,如果版本号相同则没有修改,如果相同,则是修改了的。
给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期. CAS 操作在读取旧值的同时, 也要读取版本号.
真正修改的时候:
如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).
synchronized工作过程
synchronized默认乐观锁,如果锁冲突频繁,就会转换为悲观锁
默认时轻量级锁实现,如果锁被持有的时间较长就会转换为重量级锁,轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待所实现,实现轻量级锁的时候,大概率会使用自旋锁策略。
默认是一种不公平锁,是一种可重入锁,不是读写锁。
偏向锁
让线程针对锁有一个标记(做标记很轻量),此时还没加锁,如果整个代码执行过程中,都没遇到别的线程和我竞争整个锁,就不真的加锁,但是如果有别的线程尝试来竞争这个锁,此时就立即对这个锁进行加锁(升级为真的锁,轻量级锁),此时别的线程就只能等待。
既保证了效率,也保证了线程安全。
偏向锁是synchronized内部做的工作,它会针对某个对象进行加锁,“偏向锁”只是给这个对象做个标记,如果另一个线程也尝试对同一个对象加锁,也要现场时做标记,但是此时发现标记已经有了,于是JVM就会通知到先来的线程,把锁进行升级(加锁)。
锁消除
非必要不加锁。
锁消除是编译阶段的做的优化手段,检测当前代码是否是多线程执行,如果无必要,就会在编译阶段中自动把锁去掉。
锁粗化
锁的粒度,synchronized代码块,包含代码的多少(代码越多,粒度越粗,越少,粒度越细)
一般情况下希望锁的粒度更小 (串行执行的代码少,并发执行的代码就越多。)
如果某个场景,要频繁加锁解锁,此时编译器就可能把这个操作优化成一个更粗粒度的锁,
比如以下有一个例子:
上班之后,给领导汇报ABC三个工作情况,此时,有以下几种汇报方式:
第一种:
先打个电话,汇报工作A进展,挂了电话;
再打个电话,汇报工作B进展,挂了电话;
再打个电话,汇报工作C进展,挂了电话。
第二种
打一个电话,汇报工作A,工作B,工作C的进展,再挂电话
上述打电话挂电话,就相当于频繁的加锁解锁,每次加锁解锁都会有开销,特别是释放锁以后,重新加锁,还需要锁竞争,基于这个原因,我们进行锁粗化(打电话汇报完了再挂电话),编译器就将这个操作优化成一个更加粗粒度的锁。
JUC中的常见组件
JUC(java.util.concurrent)
1.callable
Callable的用法,类似于Runnable,描述了一个任务(一个线程的具体实现方式),Runnable通过run方法描述,返回类型void,很多时候,希望这些任务有返回值,有一个具体值产出,此时就用到Callable里面的call方法。
public static void main(String[] args) {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
return null;
}
};
}
此时泛型参数写的什么,call方法的返回值类型就是什么。
public static void main(String[] args) {
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相当于取餐小票,上面写了后面取到的任务
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
//获取上述任务call方法返回值的结果
//get方法相当于join,如果线程没执行完,会阻塞等待
System.out.println(futureTask.get());
实现线程的方法:1.继承Thread; 2.实现Runnable;3.基于lambda;实现callable
2.ReentrantLock(可重入的)
与synchronized关键字不同,synchronized是基于代码块的方式来控制加锁和解锁的,
而ReentrantLock是提供了lock和unlock独立的方法,来进行加锁和解锁。
1.synchronized只是加锁解锁,加锁的时候如果发现锁被占用,只能阻塞等待,
而ReentrantLock还提供了一个tryLock方法,如果加锁失败,不会阻塞等待,直接返回false;
2.synchronized是一个非公平锁(概率均等,不遵守先来后到),
ReentrantLock提供了公平和非公平两种工作模式(在构造方法中,传入true即可开启公平锁)
3.synchronized搭配wait/notify进行等待唤醒,如果多个线程wait是同一个对象,notify的时候是随机唤醒一个,
ReentrantLock则是搭配Condition这个类,这个类也能起到等待通知,可以功能更强大(比如指定那个对象唤醒)。
3.信号量semaphore
由迪杰斯特拉(图的最短路径)提出
本质上是一个计数器,描述了当前“可用资源” 的个数
P操作,申请资源,计数器-1 申请:accqurie
V操作,释放资源,计数器+1 释放:release
如果计数器已经是0,继续申请资源,就会阻塞等待。
对于锁,本质上是一个计数器为1的信号量,取值只有1和0两种,也叫做二元信号量。
信号量是更广义的锁,不仅可以管理非0即1的资源,也能管理更多资源。
4.CountDownLatch
比如主线程,弄十个线程,主线程创建一个CountDownLatch对象,构造方法写10
是个线程分别执行各自的下载操作,主线程使用CountDownLatch.await(await方法是为了计算有几个countDown被调用了)当打,来阻塞等待所有任务完成,是个线程每个线程执行完,都调用一个CountDownLatch.countDown方法(选手到达终点),当十个线程都调用过了一户,此时主线程的await就阻塞解除了,接下来就可以进行后续工作了。
线程安全集合类
常用的,Arraylist,LinkedList,HashMap,PriorityQueue......大都是线程不安全的
1.把修改操作加锁,手动保证
2.标准库提供了一些线程安全版本的集合类
比如CopyOnWriteArrayList支持“写时拷贝”集合类
写时拷贝就是修改数据的时候就拷贝一份,相当于引用赋值操作,这样的操作就是原子的,可以保证线程安全,不用加锁也能够完成修改。
虽然不加锁,但是拷贝开销比较大,使用于不频繁修改的操作。
HashTable和ConcurrentHashMap的区别(面试题)
(1)HashTable
加锁粒度的不同,出发所冲突的频率也就不同。
HashTable是针对整个哈希表加锁,任何的增删改查操作都会出发锁,也就都会可能有锁竞争。
插入元素:根据key计算hash值 -> 数组下标
把或者新的元素给挂到对应下标的链表上(java HashMap还会在链表太长的时候,把链表变成红黑树)
如上图,线程1插入的元素,对应在下标为1的链表上,线程2插入的元素,对应在下标为2的链表上,由于是两个线程修改不同的变量,就没有线程安全问题,但是由于synchronized是加到this上,仍然会针对同一个对象进行锁竞争,就会产生阻塞等待,这样的操作很多余,没有必要,于是就引入了ConcurrentHashMap。
(2)ConcurrentHashMap
ConcurrentHashMap就不是只有一把锁,而是将每个链表的头节点作为一把锁,每次进行操作,都是针对对应链表的锁进行加锁,操作不同的链表就是针对不同德锁加锁,不会有所冲突,这样就会使大部分加锁操作实际上是没有锁冲突德,此时这里的加锁操作的开销就会很小了。
在java1.7及其之前,ConcurrentHashMap使用的是“分段锁”,目的和上述差不多,但是是几个链表公用同一把锁。
除了上面的核心区别,还有:
(1)更充分的利用了CAS机制(无锁编程),能保证线程安全,比锁更高效
(2)优化了扩容策略,对于HashTable,如果元素过多,就会涉及到扩容(扩容需要重新申请内存空间,搬运元素,就是把元素从旧的哈希表上删除,插入到新的哈希表上)
此时,搬运一次的成本就会很高,可能某一次put操作就会导致卡顿,
但是ConcurrentHashMap策略,可以化整为零,并不会试图一次性就把所有元素搬运过去,而是每次搬运一部分,当put触发到扩容,就会直接创建更大的内存空间,但是不会直接把所有元素都搬运过去,而是只搬运了一小部分,此时相当于同时存在两份hash表,此时插入元素,直接往新表插入;删除元素,删旧表或者新表的元素;查找则新表旧表都查,并且每次操作过程中,都会搬运一部分过去ConcurrentHashMap很详细可以看看
这个讲的https://blog.csdn.net/u010723709/article/details/48007881