目录
锁的策略
加锁时,处理冲突的过程中会涉及到一些不同的处理方式
第一组:乐观锁和悲观锁
乐观锁:在加锁之前,预估出现锁冲突的概率并不大,因此在进行加锁的时候就不会做太多的工作
做的事情少,加锁速度会变快,但更容易引入一些其他问题(比如消耗更多CPU资源)
悲观锁:在加锁之前,预估出现锁冲突的概率比较大,因此在进行加锁的时候就会做比较多的工作
做的事情多,加锁速度变慢,但是稳当
第二组:轻量级锁和重量级锁
轻量级锁:加锁开销小,速度更快 => 轻量级锁一般就是乐观锁
重量级锁:加锁的开销大,速度慢 => 重量级锁一般就是悲观锁
第三组:自旋锁和挂起等待锁
自旋锁就是轻量级锁的典型表现,在进行加锁的时候,搭配一个while循环,自然循环结束;如果加锁失败,不是阻塞放弃CPU,而是进行下一次循环,再次尝试获取到锁。这是一个自我循环的过程,所以名为自旋锁。自旋锁也是乐观锁。
挂起等待锁:当某个线程没有申请到锁的时候,此时该线程会被挂起,即加入到阻塞队列等待。当锁被释放的时候,就会被唤醒,重新竞争锁。好处是阻塞过程中能够把CPU资源让出来做点别的事
问题:Java中的synchronized算什么锁?
synchronized具有自适应能力,可以是上面提到的锁的任意一个。其内部会自动评估当前锁冲突的激烈程度。
当前锁冲突激烈程度不大时,就处于乐观锁/轻量级锁/自旋锁;
当前锁冲突激烈程度大时,就处于悲观锁/重量级锁/挂起等待锁
第四组:普通互斥锁和读写锁
普通互斥锁:类似于synchronized,操作涉及加锁和解锁
读写锁:把加锁分成加读锁和加写锁两种情况
读锁和读锁之间不会出现锁冲突(不阻塞);
写锁和写锁之间会发生锁冲突;
读锁和写锁之间会发生锁冲突;
一个线程加读锁的时候,另一个线程只能读不能写;
一个线程加写锁的时候,另一个线程不能写也不能读
区分数据库事务
脏读处理方法:给写加锁,写的时候不能读;
不可重复读方法:给读加锁,读的时候不能写。
为什么要引入读写锁?
如果使用synchronized加锁,两个线程进行读操作,会产生互斥和阻塞。
读写锁就可以解决上面的问题,把线程并发读的锁冲突开销给节省下来了,适用于读操作频繁,写操作较少的情况。
第五组:公平锁和非公平锁
公平:这里表示遵守先来后到
想实现公平锁,需要使用队列来记录先后顺序,可以避免线程饿死的问题
第六组:可重入锁和不可重入锁
一个线程针对这一把锁,连续加锁两次,不会死锁就是可重入锁;会死锁就是不可重入锁
synchronized属于可重入锁;系统自带的锁属于不可重入锁
可重入锁需要记录持有锁的线程是谁,计数加锁的次数
总结:
synchronized的锁策略
锁升级
当一个线程执行到synchronized的时候,如果当前对象处于未加锁的状态,会经历以下过程
1.偏向锁阶段
核心思想:懒汉模式。能不加锁,就不加锁。能晚加锁就晚加锁。
偏向锁:并非真的加锁,而是做一个轻量的标记(相当于搞暧昧)。一旦有其他线程要和我竞争这个锁,我就在其他线程之前先把锁获取到。现在就会从偏向锁升级到轻量级锁了。
如果标记完没有线程来竞争,整个过程其实就把加锁省略了。
当某个线程第一次访问一个同步块时,Java 虚拟机会将该对象的标记设置为偏向锁。此时,该线程会被记录在对象的头部,表示该线程已经获取了偏向锁。之后,如果其他线程也要访问这个同步块,虚拟机会先检查该对象的标记,如果是偏向锁,而且是自己持有的,就不会进行同步操作,而是直接进入同步块。
2.轻量级锁阶段
通过自旋锁的方式来实现
优势:另外的线程把锁释放了,就会第一时间拿到锁;
劣势:CPU消耗大
在这个阶段,synchronized会进一步统计当前在这个锁对象上有多少个线程在参与竞争。如果发现参与竞争的线程比较多了,就会进一步升级到重量级锁
对于自旋锁来说,如果同一个锁的竞争者很多,大量的线程都在自旋,整体CPU的消耗就很大了
3.重量级锁阶段
此时拿不到锁的线程就不再自旋了,而是进入阻塞等待,让出cpu
锁消除
synchronized内置的优化策略
编译器编译这个代码的时候,如果发现这段代码,不需要加锁就会自动把锁干掉
锁粗化
会把多个细粒度的锁合并成一个粗粒度的锁
细粒度:synchronized{ },大括号里面包含的代码越少,锁的粒度越细;相反越粗
一般锁的粒度越细,更有利于多个线程并发执行;但是由于每次加锁都会造成阻塞,所以粗化也能提高效率
CAS
compare and swap:一个特殊的CPU指令,完成比较和交换的工作
下面是一段伪代码
if中比较address内存地址中的值是否和expected寄存器中的值相同。如果相同就把swap寄存器的值和address内存中的值进行交换(其实就是赋值,因为一般寄存器用完之后就丢掉了)
CAS作为一条原子的CPU指令,就能完成上面代码的工作了。
之前保证线程安全都是靠加锁,加锁-->阻塞-->性能降低,而使用CAS,不涉及加锁,也不会阻塞,合理使用也能保证线程安全。--无锁编程
Java标准库中封装CAS的原子类
CAS的线程安全
原先代码(线程不安全)
public class ThreadDemo12 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = "+count);//输出不了100000
}
}
用CAS工具包修改
package Thread;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadDemo12 {
//private static int count = 0;
//不使用原生的int,而是替换成AtomicInteger
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
//count++;
count.getAndIncrement();
//++count;
//count.incrementAndGet();
//count += n;
//count.getAndAdd(n);
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
//count++;
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = "+count.get());//输出100000
}
}
原理:之前的count++是三个CPU指令,多线程的三个指令会穿插执行,造成线程不安全
这里的getAndIncrement对变量的修改就是一个天然的原子CAS指令
(之前的加锁操作就是为了让三个指令编程原子的)
CAS的ABA问题
CAS在使用时,关键要点是要判定当前内存的值是否和寄存器中的值是一样的。本质上就是判定当前这个代码执行过程中是否有其他线程穿插进来。
可能存在这样的情况,数值原来是A,执行CAS之前,另一个线程把这个值从A改成B,又从B改成A。这个修改值又改回去的操作是在其他线程进行穿插时进行的,CAS无法感知到,这就是ABA问题,上面这种情况一般没啥问题。
那这个问题什么时候会出现bug呢?
假设去银行取钱,初始账户余额1000,取500,取钱的时候ATM卡了,按一下没反应又按一下
假设ATM内部有t1和t2两个线程进行扣款操作,但是t1在执行CAS之前,出现一个t3线程给账户充值500
CAS(oldBalance, balance, oldBalance - 500)
在t2线程中,如果oldBalance和balance的值相同,则oldBalance - 500并将计算结果赋值给balance,balance = 500。正常来说,t1线程里面判断balance != oldBalance就可以推出了,但是此时t3线程又给balance充了500块,balance = 1000,如下图
ABA解决方案:
1.约定数据变化只是单向的(只增加或只减少),不能是双向的(又增加又减少)
2.对于本身就必须双向变化的数据,可以给它引入一个版本号(版本号是单向的)
其他小知识
Callable接口:创建线程的第四种方式
前三种:1)继承Thread(包含匿名内部类),2)实现Runnable(包含匿名内部类),3)基于lambda表达式
第四种:使用Callable接口
区分Runnable和Callable
Runnable关注的是这个过程,不关注执行结果。因为其提供的run方法返回的是void
Callable关注执行结果。其提供的call方法返回线程执行任务得到的结果
使用Runnable计算并获取计算结果,非常不优雅!!
但是使用Callable就可以不使用成员变量优雅地获取到计算结果
这里的<V>是期望线程的入口方法里返回值是啥类型,此处的泛型参数就是啥类型
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class ThreadDemo14 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int result = 0;
for (int i = 0; i <= 1000; i++) {
result += i;
}
return result;
}
};
//因为Thread没有提供构造函数来传入callable,
//所以我们可以引入FutureTask类来作为Thread和callable的粘合剂
FutureTask<Integer> futureTask = new FutureTask<>(callable);
//这里的futureTask是未来的任务(因为当前任务还没执行完)执行完毕,去取结果的一个凭证
Thread t = new Thread(futureTask);
t.start();
//接下来的代码不用join,直接使用futureTask获取到结果
//futureTask.get()带有阻塞功能,如果线程还没执行完毕,get就会阻塞
//线程执行完了,return的结果就会被get返回回来
System.out.println(futureTask.get());
}
}
可重入锁:ReentrantLock
synchronized也是可重入锁,而且功能强大,为啥还要有ReentrantLock?
1.ReentrantLock提供tryLock操作。
一般的lock是直接进行加锁,如果加锁不成就要阻塞
而tryLock是尝试进行加锁,如果加锁不成,不阻塞直接返回false。这样可以提供更多可操作空间
2.ReentrantLock提供公平锁的实现,通过队列记录加锁线程的先后顺序
而synchronized是非公平锁
3.搭配的等待通知机制不同
对于synchronized,搭配wait / notify
对于ReentrantLock,搭配Condition类,功能比wait和notify强
信号量 Semaphore
比如我们去停车场,门口通常有一个电子牌,写着剩余xx个车位,这里的xx就是信号量
表示可用资源的个数,申请一个可用资源,数字就会-1,这个操作称为P操作
释放一个可用资源,数字就会+1,这个操作称为V操作
锁也可以认为是计数值为1的信号量。
释放状态就是1,加锁状态就是0。这种非0即1的信号量称为二元信号量
经典的t1和t2线程安全问题也可以用semaphore作为锁进行处理
package Thread;
import java.util.concurrent.Semaphore;
public class ThreadDemo15 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(1);
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
try {
semaphore.acquire();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
count ++;
semaphore.release();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
try {
semaphore.acquire();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
count ++;
semaphore.release();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
Semaphore也可以用来实现生产者消费者模型
定义两个信号量,一个用来表示队列中有多少个可以被消费的元素,sem1
另一个用来表示队列中有多少个可以放置新元素的空间,sem2
生产一个元素,sem1.V(), sem2.P()
消费一个元素,sem1.P(), sem2.V()
CountDownLatch
比如多线程下载一个文件,这个文件可能很大,但是我们可以拆成多个部分,每个线程负责下载一个部分。下载完成之后,最终把下载的结果拼在一起
拼到一起的前提是所有线程都执行完毕,那怎么知道这些线程执行完毕了呢?
使用CountDownLatch可以很方便感知到这件事,无需多次调用join(借助join的方式,只能使每个线程执行一个任务,而countDownLatch可以让一个线程执行多个任务
import java.util.Random;
import java.util.concurrent.CountDownLatch;
public class ThreadDemo16 {
public static void main(String[] args) throws InterruptedException {
//构造方法中写一个10表示有10个任务要执行
CountDownLatch latch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
int id = i;
Thread t = new Thread(()->{
Random random = new Random();
int time = (random.nextInt(5) + 1) * 1000;
System.out.println("线程 " + id + " 开始下载");
try {
Thread.sleep(time);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("线程 " + id + " 结束下载");
//告知countDownLatch线程执行结束了
latch.countDown();
});
t.start();
}
//通过这个await操作等待所有任务结束
latch.await();
System.out.println("所有任务都已完成");
}
}
多线程环境使用哈希表
HashMap不行,因为线程不安全
HashTable在关键方法上加上synchronized,更靠谱。遗憾的是,锁冲突发生的概率比较大
更好的解决方案:ConCurrentHashMap
1.缩小了锁的粒度
hashtable直接拿一个大锁锁住所有的链表
上述这种加锁方式也称为锁桶
2.充分使用CAS原子操作,可以减少一些加锁
3.针对扩容操作的优化
扩容是一个重量操作
回顾:负载因子--描述了每个桶上平均有多少个元素
如果桶上链表中元素个数太多,两种处理方法:
1.长度不平均的情况:变成树
2.扩容操作:创建一个更大的数组,把旧的哈希表上的元素都搬运到新的数组上
缺点:如果哈希表中元素很多,这里的扩容操作就会消耗很长时间,同时无法控制何时触发扩容
ConCurrentHashMap的扩容操作
每次操作只搬运一部分元素,虽然可能搬运次数会比较多,花的时间比较长,但是每次操作的时间比较短而且易控制