常见的锁策略
乐观锁 vs 悲观锁
乐观锁:预测锁竞争不是很激烈
悲观锁:预测锁竞争会很激烈
轻量级锁 vs 重量级锁
轻量级锁加锁解锁开销比较小,效率更高
重量级锁加锁解锁开销比较大,效率更低
多数情况下,乐观锁,也是一个轻量级锁(不能完全保证)
多数情况下,悲观锁,也是一个重量级锁(不能完全保证)
自旋锁 vs 挂起等待锁
自旋锁 是一种典型的轻量级锁,
挂起等待锁 是一种典型的重量级锁
给大家举个例子:我要向女神表白,但是女神告诉我她已经有男朋友了,对我而言我是不会放弃的(等待机会,锁被释放,女神分手)
自选锁:每天都给女神问候,一旦女神分手,就能第一时间感知到,从而有机会获取到锁,很明显,自选锁占用了大量的系统资源
挂起等待锁:一直默默地等待女神分手,如果女神分手了,有可能会告诉我,她分手了,但是也有可能女神把我忘了,指不定啥时候才能想起,这样把CPU省下来了。
自选锁优点:没有放弃CPU,不涉及线程阻塞和调度,一旦被释放,就能第一时间获取到锁 缺点:如果锁被其他线程占有的时间比较长,会消耗CPU资源,而挂起等待锁不消耗CPU。
互斥锁vs读写锁
互斥锁:像synchronized这样的锁提供加锁和解锁两个操作,如果一个线程加锁了,另一个线程也尝试加锁,就会阻塞等待。
而读写锁:提供三种操作1.针对读加锁 2.针对写加锁3.解锁 多个线程针对同一个变量读,这时候没有线程安全问题,也不需要加锁控制,如果当前一组操作有读也有写或者都是写,这时候会有线程安全问题,会产生锁竞争。在开发场景中,读操作非常高频,比写操作频率高很多
公平锁vs非公平锁
公平锁,当锁释放后,由等待队列中,最早的线程(等待时间最长)获取到锁
不可重入锁 vs 可重入锁
不可重入锁:一个线程针对同一把锁,连续加锁两次,出现死锁
可重入锁:一个线程针对同一把锁,多次加锁,不会死锁
synchronized
synchronized 即是一个悲观锁,也是一个乐观锁,synchronized默认是一个乐观锁,但是如果发现当前锁竞争比较激烈,就会变成悲观锁
synchronized即是轻量级锁,也是一个重量级锁,synchronized默认是轻量级锁,如果发现当前锁竞争比较激烈,会转换成重量级锁
synchronized这里的轻量级锁,是基于自选锁的方式实现的,synchronized这里的重量级锁,基于挂起等待锁实现
synchronizedf不是读写锁
synchronized是非公平锁
synchronized是可重入锁
CAS(Compare and swap)比较并交换
比较A和V是否相等
如果相等,将B和V的值交换
返回操作是否相等
上述交换的过程,大多数不关心B的情况,因此这里的交换也可以认为是赋值
上述CAS的过程,并非是通过一段代码实现的,而是通过一条CPU指令完成的,CAS操作是原子的,这样可以回避线程安全问题,这样为解决线程安全问题除了加锁又提供了一种方法。
CAS在处理++操作时会有这独特的作用,这时我们可以不用通过加锁的方式。
public static void main(String[] args)throws InterruptedException {
AtomicInteger count=new AtomicInteger(0);
Thread t1=new Thread(()->{
for(int i=0;i<5000;i++)
{
count.getAndIncrement();//相当于count++
}
});
t1.start();
Thread t2=new Thread(()->{
for(int j=0;j<5000;j++)
{
count.getAndIncrement();
}
});
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
CAS的应用场景:
实现原子类
实现自旋锁:
CAS的典型问题:
CAS在运行中的核心,检查value和oldvalue是否一致,如果一致,就交换,这里的一致可能是没改过,但也可能是改过了,又还原回来了。举个例子:把value的值设为A,cas判定value为A,此时确实value始终是A.也可能是value本来是A,被改成了B,又被还原成了A,ABA这种情况,大部分情况下是不会对代码产生影响的,但是不排除一些极端的情况,也是会产生影响的。给大家举个例子,假设我要去银行准备取钱600,当前账户的余额是1000,当按下取款机的那一刻,机器卡了下,我就多按了几下,这就可能产生bug,可能触发重复扣款的操作。
像这种问题出现的概率低,但也会出现,解决这种问题做好的办法就是加入一个版本号,初始版本号是1,每次修改,版本号都加1,然后进行CAS的时候,不是以金额为基准,而是以版本号为基准。
还是以取钱为例子,假设线程1余额为1000,打算取600,版本号为1,这时取款机卡了,多按了一下,线程2余额也为1000,取600,版本号为1,线程1CAS取款成功,余额为400,版本号加1变为2,这时朋友转账600过来,余额又变为1000,版本号加1变为3,此时线程2余额和之前读到的一样,但是版本号之前取到的是1,这时成了3,CAS返回false,取款失败。
Synchronized 原理
synchronized内部有一些优化机制,存在的目的是让这个锁更高效,实用。
锁升级/锁膨胀
(1)无锁
(2)偏向锁
(3)轻量级锁
(4)重量级锁
synchronized(locker)
{
}
当代码执行到这,首先会进入偏向锁状态,偏向锁并不是真正的加锁,而只是占个位置,有需要再真加锁,没需要就算了。synchronized的时候,并不是真正的加锁,先偏向锁状态,做个标记(这个过程是非常轻量的)如果在整个使用锁的过程中,都没有出现锁竞争,在synchronized执行完之后,取消偏向锁即可,但是如果在使用过程中,另一个线程也在尝试加锁,在它加锁之前,迅速的把偏向锁升级为真正的加锁状态,另一个线程也只能阻塞等待了。
当synchronized发生锁竞争的时候,就会从偏向锁,升级为轻量级锁,此时当synchronized相当于是通过自旋的方式,来进行加锁的,如果要是很快别人就释放锁了,自旋是划算的,但是如果迟迟拿不到锁,一直自旋,并不划算,synchronized自旋并不是一直的自旋,自旋到一定程度之后,就会升级到重量级锁(挂起等待锁),挂起等待锁则是基于操作系统原生的API来进行加锁,linux原生提供了mutex一组API,操作系统内核提供的加锁功能,这个锁会影响到线程的调度,此时如果线程试图进行重量级加锁,并且发生锁竞争,此时线程会被放到阻塞队列中,暂时不参与CPU调度,直到锁被释放了,这个线程才有机会被调度到,并且有机会获取到锁。
锁消除:
编译器智能的判定,看当前的代码是否需要真正的加锁,如果这个场景不需要加锁,但是程序员加了,就会自动的把锁干掉。
锁粗化:
锁的粒度:synchronized包含的代码越多,粒度就越粗,包含的代码越少,粒度就越细,通常情况下认为锁的粒度细一点好,但是有一些情况,锁的粒度粗一些更好
Callable接口类似于Runnable,Runnable用来描述一个任务,描述的任务没有返回值,Callable也是用来描述一个任务,此任务有返回值。如果需要一个线程计算出结果再返回,使用Callable更合适
public static void main(String[] args) {
// int count=0;
Callable<Integer>a=new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int count=0;
for(int i=0;i<100;i++)
{
count++;
}
return count;
}
};
FutureTask<Integer>futureTask=new FutureTask<>(a);
Thread t1=new Thread(futureTask);
t1.start();
try {
t1.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
try {
Integer result=futureTask.get();//获取结果
System.out.println(result);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} catch (ExecutionException e) {
throw new RuntimeException(e);
}
}
}
ReentrantLock
ReentrantLock是标准库提供的另一种锁,是“可重入的”,synchronized是基于代码块的方式来加锁解锁的,ReentrantLock使用了lock方法和unlock方法加锁解锁
public static void main(String[] args) {
ReentrantLock reentrantLock=new ReentrantLock();
reentrantLock.lock();
reentrantLock.unlock();
}
但是这样的写法是存在问题的:
public static void main(String[] args) {
ReentrantLock reentrantLock=new ReentrantLock();
reentrantLock.lock();
if(true)
{
return;//直接返回了,但是锁还没有释放
}
reentrantLock.unlock();
}
解决这样的问题:我们可以把unlock放在Finally语句中
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
try {
reentrantLock.lock();
if (true) {
return;//直接返回了,但是锁还没有释放
}
if (3 > 1) {
return;
}
} finally {
reentrantLock.unlock();
}
}
上面是ReentrantLock的劣势,但是也是有优势的
ReentrantLock提供了公平锁版本
ReentrantLock reentrantLock = new ReentrantLock(true);
只需把括号里写上true,如果是false就是非公平锁版本
对于synchronized来说,加锁操作就是死等,只要获取不到锁,就一直死等,ReentrantLock则提供了更灵活的方法
reentrantLock.tryLock();
无参数版本,能加上锁就加,加不上就放弃
有参数版本,能加上锁就加,加不上锁,就等待指定时间,到时间还没加上,就放弃
ReentrantLock提供了一个更强大的等待机制
synchronized搭配wait、notify使用,notify随机唤醒一个等待的线程,而ReentrantLock能唤醒指定的线程
public static void main(String[] args)throws InterruptedException {
AtomicInteger count=new AtomicInteger(0);
Thread t1=new Thread(()->{
for(int i=0;i<5000;i++)
{
//count.getAndIncrement();//相当于count++
//count.incrementAndGet();
count.addAndGet(2);
}
});
t1.start();
Thread t2=new Thread(()->{
for(int j=0;j<5000;j++)
{
//count.getAndIncrement();
//count.getAndDecrement();
count.incrementAndGet();
}
});
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
信号量 Semaphore
信号量本质上就是一个计数器,描述了一个“可用资源的个数”P操作:申请一个可用资源,计数器就要-1,V操作:释放一个可用资源,计数器就要+1,P操作如果是计数器为0,继续P操作,就会出现阻塞等待的情况。
考虑一个计数初始值为1的信号量,针对这个信号量的值,就只有1和0两种取值(信号量不能是负的)执行一次P操作1->0,执行一次V操作0->1.如果已经执行了一次P操作了,再进行一次P操作,就会阻塞等待。
锁(锁也可以视为是计数器为1的信号量,二元信号量)锁是信号量的一种特殊表达,信号量是锁的一般表达。
实际中开发中,最经常用到的是锁,但是信号量也是偶尔会用到的
public static void main(String[] args) throws InterruptedException{
Semaphore a=new Semaphore(4);4表示可用资源的个数
a.acquire();
System.out.println("申请一个资源空间");
a.acquire();
System.out.println("申请一个资源空间");
a.acquire(2);
System.out.println("申请两个资源空间");
//此时信号量为0,会阻塞等待
a.acquire();
System.out.println("申请一个资源空间");
}
public static void main(String[] args) throws InterruptedException{
Semaphore a=new Semaphore(1);
a.acquire();
System.out.println("申请一个资源空间");
a.release();
System.out.println("释放一个资源空间");
}
CountDownLatch(用于特定的场景)
给大家举个例子:有一场游泳比赛,这场游泳比赛,开始时间是明确的(裁判的指令)结束时间是不明确的(所有选手都冲过终点线)为了等待这个游泳比赛结束,就引入这个CountDownLatch
主要是两个方法:
await(等待所有的线程)主线程调用这个方法
countDown表示选手冲过了终点线
countDownLatch 在构造的时候,指定一个计数(选手的个数)
例如:指定三个选手进行比赛
初始情况下,调用await,就会阻塞,每个选手冲过终点,都会调用countDown方法,前两次调countDown,await没有任何影响。第三次调用countDown,await就会被唤醒,返回(解除阻塞),此时就可以认为整个比赛都结束了。
在实际开发中countDownLatch也是有很多使用场景的,比如下载一个大文件
Vector、Stack、HashTable 这几个是少数的线程安全类,其关键方法都带有synchronized。
多线程环境使用ArrayList
自己加锁,自己使用synchronized或者ReentrantLock
Collections.synchronizedList 这里会提供一些ArrayList的相关方法,同时是带锁的,使用这个方法把集合类套一层
CopyOnWriteArrayList
也叫做“写时拷贝”,当进行读操作时,ArrayList不进行任何操作,当进行写操作时,我们拷贝一份新的ArrayList,修改我们在新的ArrayList上修改,读操作我们在旧的ArrayList上读,显然这种方法的优点是不用加锁,但是也有局限性,只适合于数组比较小的情况。
多线程使用哈希表
HashMap是线程不安全的,HashTable是线程安全的,给关键方法加上了synchronized,更好的是ConcurrentHashMap,更优化的线程安全哈希表
那么ConcurrentHashMap相比于HashTable又有哪些优点呢?
ConcurrentHashMap把大锁转化成了多把小锁,大大降低了锁冲突的概率,HashTable是直接给方法加上了synchronized,只要操作哈希表上的元素就都会加锁,也就都会产生锁冲突。
像这里的1,2线程1修改变量1,线程2修改变量2,此时会有线程安全问题,如果这两个元素相邻,就需要修改会产生线程安全问题,但是像1、3或者2、3这
不同的变量,我们在对其进行修改的时候,不会涉及到线程安全问题。但因为此时各个链表上的锁
对象一样,也会发生锁冲突。
此时,针对1 2锁的粒度变小了,针对同一把锁进行加锁,会有锁竞争,会保证线程安全
针对3 4这个情况,针对不同的锁进行加锁,不会有锁竞争,没有阻塞等待,程序就会更快(快是相对的,不会比不加锁快)。
ConcurrentHashMap 做了一个激进的操作,针对读操作,不加锁,只针对写操作,加锁。读和读之间没有冲突,写和写之间有冲突,读和写之间也没有冲突,有同学可能会问读和写操作不加锁,不会产生“脏读“问题吗?是因为这里用了volatile+原子的写操作。
ConcurrrntHashMap 内部充分利用了CAS,通过这个也来进一步的削减加锁操作的数目,比如维护元素个数
针对扩容,采取了“化整为零”的方式,HashMap HashTable扩容,创建一个更大的数组空间,在旧的数组链表上的每个元素 搬到新的数组上(删除+插入)这个扩容操作会在某次put的时候进行触发,如果元素个数特别多,就会导致这样的搬运操作,比较耗时,就会出现某次put比平时的put卡很多倍。
ConcurrentHashMap,扩容采取的是每次搬运一小部分元素的方式,创建新的数组,旧的数组也保留,每次put操作,都会往新数组上添加,同时进行一部分搬运(把一小部分旧的元素搬到新数组上)
每次get的时候,旧数组和新数组都查询
每次remove的时候,把元素删了就行。