乐观锁
预计在线程中数据大概率不会被其他线程拿去修改
对于加锁所作的准备较少。只有当修改的操作真正发生了,才会进行加锁操作
所以乐观锁适用于多读少写的情况,可以降低加锁频率,提升效率。
悲观锁
所以悲观锁适用于对于线程安全要求高的场景。
轻量级锁
重量级锁
乐观和悲观是对于未加锁结果的一种猜想
重量轻量是对于加锁后资源消耗的一种评价
从这个角度说,这两组概念都是在描述加锁的工作对于资源的消耗。乐观就是消耗的资源较少,悲观反之。
自旋锁
重复、快速地进行锁的获取(称为自旋“)
会增大cpu的消耗,可能会造成“忙等”
适用于乐观、轻量的策略(虽然一直不停地在获取锁,但是过不了多久就能够真正获取到锁)
伪代码:
while(抢锁失败) {
加锁...
}
优点:
在锁竞争平缓的情况下能够降低资源消耗,加快运行速度,避免线程之间因为简单的任务而阻塞。
其他线程一旦把锁释放,就会第一时间拿到锁
缺点:
- 如果遇到锁竞争激烈的情况,会有其他线程竞争不到锁的情况。
- 竞争不到锁,但是还会一直进行获取锁,造成cpu的资源浪费
挂起等待锁
遇到锁竞争的情况就挂起等待
适用于锁竞争激烈的情况
与悲观、重量相对应
伪代码:
while(抢锁失败) {
wait();
}
优点:
避免了cpu资源浪费
缺点:
不能第一时间抢到锁,什么时候能加锁,由系统决定
普通互斥锁
读写锁
读锁
读的操作并没有线程安全问题,但是只局限于读,如果读的过程中,把正在读取的值拿走进行修改,那么就会产生读到“脏值”的情况。
写锁
写就是修改,修改就会有线程安全问题,如果在修改的过程中读就可能会读到“脏值”,如果在修改的过程中继续修改就可能会引起数据的混乱。
公平锁
非公平锁
这里的“公平”和“非公平”只是基于前人发明的角度上,其实,在“等概率竞争锁”的情况下也是一种“公平“。
可重入锁
可以重复加锁
synchronized就是可重入锁
伪代码:
lock();// 第一次加锁之后继续加锁
lock();// 第二次加锁>
不可重入锁
乐观锁-轻量级锁-自旋锁都是对应的
悲观锁-重量级锁-挂起等待锁是对应的
乐观:认为自己的家不会被偷,那就在家被偷或者被贼盯上的时候再去给门上锁
悲观:认为自己的家已经被贼盯上了,一直上着锁
轻量级锁:只给家门上一个容易打开的锁,开锁的时候消耗自己的时间精力也会较少,乐观地认为贼不会偷有锁的家
重量级锁:给家门上一个不容易打开的防盗锁,,开锁的时候消耗自己的时间精力会增加,悲观地认为一直有贼盯上我的房子
自旋锁: 乐观地认为没有多少贼盯上了自己的家,所以每天都去看看自己的家有没有被偷,优点是家一被偷就能够知道,就能立马上锁,缺点是费时费力
挂起等待锁:悲观地认为有很多贼已经盯上了自己的家,所以上一把不容易打开的锁,当有人敲门的时候再去检查是谁来,如果是贼那就不开门,让锁挂起等待,自己继续在家里玩游戏。好处是减少了自己的任务量,可以有效防止多个贼同时顶上自己家的情况,缺点是这把锁自己也不容易打开
普通互斥锁:
乐观锁的常用实现算法是CAS算法,全称是CompareAndSwap(比较和交换),这个也是cpu中存在的一条cas指令。
伪代码:
boolen cas(address, expectedValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
address表示内存,expectedValue和swapValue是两个寄存器。
如果内存中的值与交换前所期望的值相等,那就与准备交换的值进行交换(说是交换,其实就是赋值)。
expectedValue就是内存中值的”旧值“,通过与这个”旧值“进行比较,就能够发现此值在修改前是否进行了改动,防止读到”脏值“。
synchronized锁是一把自适应锁,当一个线程执行到synchronized的时候,如果这个线程还未加上锁,那么synchronized就会经理以下过程:
偏向锁阶段
核心思想就是”懒汉模式”,非必要不加锁(升级成轻量级锁就是必要的时候)。
感觉有点像占着茅坑不拉屎,一旦有人来了就蹲下拉屎,没人来也就占着。(学校的占座不就是这样吗)
轻量级锁阶段
随着线程之间少量的锁竞争,偏向锁状态被消除(并不是解锁了),进入轻量级锁阶段,这个阶段由自旋锁进行实现
synchronized内部会统计当前这个锁对象有多少个线程想要竞争,如果数量多,那么还会升级为重量级锁
因为锁的竞争大的话,对于自旋锁来说大量的线程都在自旋,这样不能提高效率,反而会带来更多的cpu消耗。
重量级锁阶段
synchronized锁是:
系统原生mutex锁是:
当编译器发现加锁的这一部分代码中,并未涉及变量修改的部分,那么就会自动将锁去掉。
使用线程安全的StringBuffer举个例子:
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
这段代码中虽然每个append 都有加锁解锁的操作,但是jvm+编译器都发现这段代码并没有真正的线程安全问题,所以就不会进行加锁和解锁,避免了资源的无谓消耗。
与锁粗化关系密切的一个重要概念是锁的粒度。
锁的粒度就像吃的牛肉粒一样,每次加一个锁就是一个牛肉粒,有两种策略吃:
两种伪代码分别对应这两种情况:
for() {
synchronized(locker) {
// TODO
}
}
将锁粗化:
synchronized(locker) {
for() {
// TODO
}
}
循环等待
当锁A唤醒需要锁B先释放,锁B唤醒需要锁A先释放,就构成了循环等待。
请求和保持
当一个线程获取新锁的同时,它会继续对于当前已有的锁进行占有。
互斥使用
资源被一个线程占有时,其他的线程不能同时使用。
不可抢占
资源被一个线程占有时,其他线程不能抢占使用,只能等待当前占有者主动释放。
其中,3和4是锁的特性无法改变,但是1和2可以通过代码结构进行破坏。
比较著名的是“哲学家就餐问题”:
当这5名哲学家都需要吃饭的时候,餐桌上拥有的筷子数量肯定是不够用的,但是如果给哲学家加上拿筷子的顺序,那么就只会有一名哲学家在“阻塞等待”。
比如:规定每名哲学家只能先拿起编号小的筷子,那么就是0号老铁先吃
1号老铁进行阻塞等待,等到0号老铁吃完以后再先拿起编号小的1号筷子+2号筷子吃
2,3,4老铁同理。
就是调整代码结构,破除循环等待这个条件。
Callable是一个接口,相当于对于线程进行了一个返回值的封装,与Runnable接口不同之处在于:
Callable的运行结果需要借助FutureTask进行get得到。
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class test1 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
// 计算从1加到1000的值
int ret = 0;
for (int i = 1; i <= 1000; i++) {
ret += i;
}
return ret;
}
};
// 使用FutureTask对于Callable与Thread进行“粘合”
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread thread = new Thread(futureTask);
thread.start();
System.out.println(futureTask.get());
}
}
运行结果:
相同点:
- synchronized 和 ReentrantLock 都是 Java 中提供的可重入锁
不同点:
- 用法不同:synchronized 可以用来修饰普通方法、静态方法和代码块;ReentrantLock 只能用于代码块;
- 获取和释放锁的机制不同:进入synchronized 块自动加锁和执行完后自动释放锁; ReentrantLock 需要显示的手动加锁和释
- 锁;
- 锁类型不同:synchronized 是非公平锁; ReentrantLock 默认为非公平锁,也可以手动指定为公平锁;
- 响应中断不同:synchronized 不能响应中断;ReentrantLock 可以响应中断,可用于解决死锁的问题;
- 底层实现不同:synchronized 是 JVM 层面通过监视器实现的;ReentrantLock 是基于 AQS 实现的。
本质上是一个计数器
用一个信号表示当前还有多少可用资源,有两个操作:
public class test2 {
public static void main(String[] args) throws InterruptedException {
// 表明当前还有4个可用资源
Semaphore semaphore = new Semaphore(2);
// 进行获取资源
semaphore.acquire();
System.out.println("已成功申请资源");
semaphore.release();
System.out.println("已成功释放资源");
// 阻塞
semaphore.acquire();
System.out.println("进行第一次资源申请");
semaphore.acquire();
System.out.println("进行第二次资源申请");
semaphore.acquire();
System.out.println("进行第三次资源申请");// 会进行阻塞,因为总共只有2个可用资源
}
}
运行结果:
同时等待N个线程执行结束后,通过await()方法,执行后续代码。
import java.util.concurrent.CountDownLatch;
public class test3 {
public static void main(String[] args) {
final int threadNum = 10;
// 表示有10个线程任务需要完成
CountDownLatch countDownLatch = new CountDownLatch(threadNum);
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
// 每次执行完之后countDown
countDownLatch.countDown();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
};
// 创建10个线程
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(runnable);
thread.start();
System.out.println(i);
}
// 起到分界线的作用,在countdownLatch中预定的线程未执行完时不会进行
try {
countDownLatch.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("线程全部执行完毕");
}
}
Stack、Vector、Hashtable都是线程安全的。
public class test4 {
public static void main(String[] args) {
// 实现一个带有线程同步的ArrayList
Collections.synchronizedList(new ArrayList<>());
}
}`
ArrayBlockingQueue 基于数组实现的阻塞队列
LinkedBlockingQueue 基于链表实现的阻塞队列
PriorityBlockingQueue 基于堆实现的带优先级的阻塞队列
TransferQueue 最多只包含一个元素的阻塞队列
因为整个对象都是一把锁,所以即使是访问同一哈希表上不同链表的时候也会造成锁竞争。
所以需要进行锁粗化,引出“ConcurrentHashMap”。
锁粗化后,Hashtable上的每个链表各自占有一把锁,降低锁冲突的概率
size属性利用CAS操作进行维护,提高效率
针对扩容进行优化,采用**“化整为零”**思想(CopyOnWrite)
Hashtable会将整个数组进行拷贝,但是每次只会复制一些,分步完成,不会造成像Hashtable一样的暂时卡顿现象。
但是,会造成两个同时存在的数组:
- 查找、删除操作需要在两个数组中都操作
- 插入只需要在复制出来的新表上操作