乐观锁:假设数据一般不会产生并发冲突,所以在数据提交的时候才会检测是否产生了并发冲突,如果发现并发冲突了,就会返回错误的信息,让用户决定该如何去做
悲观锁:假设数据发生并发冲突的概率比较高,每次读写数据的时候都会加锁,这样就不会发生线程不安全,别的线程想要获取锁的时候,就要等待它释放锁
synchronized初始使用乐观锁策略,当发现锁竞争比较频繁的时候,就会自动切换为悲观锁策略
乐观锁的一个重要功能就是要检测出数据是否发生发生冲突,我们可以引入一个"版本号"来解决
比如说线程1和线程2都要对内存中的count进行自增,内存中的count不仅存储了原始数据值0,还会存储一个版本号(version),初始值为1,当线程1,线程2从内存中读取count时,就会将数值和版本号都存储到各自的工作内存中(寄存器),当两个线程在自己的工作内存中对count值进行自增后,也会将version+1,假如线程1先进行修改,修改完成后,此时线程1寄存器中的数据就是count=1,version=2,再将数据写入内存时,会先判断version是否大于内存中的version,内存中的version=1,所以线程1就会将数据写入内存中,线程1写入成功。此时线程2在寄存器中也完成了对数据的修改,count=1,version=2,当线程2将数据写入内存时,判断version是否大于内存中的version,发现version不大于内存中的version,此时线程2就会写入失败。也就是要写入内存的数据的版本号必须大于当前内存中的版本号
发生线程不安全问题的其中一点原因就是多个线程同时修改一个数据,但是如果多个线程同时读取一个数据,就不会发生线程不安全问题,很多时候我们读取数据的频率高,而修改数据的频率低,此时这两种情况下都用同一个锁,会产生极大的损耗,所以我们需要使用读写锁。
读写锁:就是将读操作和写操作区分对待,读操作之间不互斥,写操作之间互斥,读操作和写操作之间互斥
Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.
synchronized不是读写锁
重量级锁与轻量级锁之间是用加锁开销的大小来区分。
如果加锁解锁是通过操作系统内核完成,会涉及到用户态和内核态之间的切换,也会涉及到一系列的阻塞等待和线程调度,此时,这个锁就称为是重量级锁
如果加锁解锁开销更小,一般在用户态完成,不太容易引发线程调度,这个锁就称为轻量级锁
synchronized开始是轻量级锁,如果锁冲突比较严重,就会变成重量级锁
如果两个线程发生锁冲突,一个线程没有竞争到锁,就会放弃CPU,进入阻塞等待,不再占用CPU资源,需要过很久才会被再次调度,但一般情况下,锁被释放的时间很快,所以不需要长时间等待,就需要通过自旋锁来完成
自旋锁:如果锁竞争失败,就会一直循环尝试获取锁,直到获取到锁,一旦锁被其他线程释放,就会立即获取到锁,
自旋锁的优缺点:
自旋锁是一种轻量级锁的实现方式,优点是没有放弃CPU,不涉及到线程阻塞和调度,一旦锁被释放,立即获取锁,
缺点是如果锁被其他线程长时间占用,一直尝试获取锁会长时间占用CPU,浪费CPU资源。
synchronized轻量级锁策略大概率是通过自旋锁的方式实现的
公平锁:遵守"先来后到"的原则,多个线程想要获取同一把锁时,就会按照阻塞等待的先后顺序来获取锁。例如有3个线程A,B,C按先后顺序排列,当锁被释放后,A会先获取锁,释放锁后,B再获取锁,然后C再获取锁
非公平锁:A,B,C,3个线程同时竞争同一把锁,但是它们3个获取到锁的概率是相同的,并不区分谁先来的,谁后来的。
区分公平锁非公平锁的条件:是否遵守"先来后到"的原则,
操作系统内部的线程调度就可以视为是随机的,如果不做任何额外的限制,锁就是非公平锁,要想实现公平锁,就需要依赖额外的数据结构,记录线程的先后顺序
synchronized是非公平锁
CAS:全称Compare and swap 字面意思就是比较并交换
假设内存中有一个原始数据V,线程中有一个旧的原始数据A,需要新修改的值B
先比较A是否和V相等,如果相等,就将V改成B,如果不相等,就修改失败
如果修改成功了,返回TRUE,修改失败返回FALSE
CAS相当于将这3个操作封装起来,变成一个整体的操作,CAS操作是原子的
CAS应用
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
伪代码实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
//CAS比较value和oldvalue是否相等,如果相等,就将oldvalue+1,如果不相等,就将oldvalue替换为value,继续循环CAS操作。
伪代码:
public class SpinLock {
private Thread owner = null;//owner为null,表示锁没有被线程获取
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
//while判断此时的owner和null是否相等,如果相等,就将自己线程的引用赋值给owner,相当于获取锁成功,赋值之后,返回true,由于while判断的是!CAS,所以结果为false,就会退出循环。如果没有赋值成功,返回的是false,!false=true,就会一直循环的尝试获取锁。此时就实现了自旋锁
}
}
public void unlock (){
this.owner = null;
}
}
ABA 的问题:
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.
接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要先读取 num 的值, 记录到 oldNum 变量中.
使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
线程 t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这个时候 t1 究竟是否要更新 num 的值为 Z 呢?
到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程
ABA问题引出的BUG
大部分情况下,t2线程这样的一个反复横跳的改动,对于t1是否修改num是没有影响的,但也有一些特殊情况
假如A有200的存款(value),他想要取ATM取出100,由于ATM卡了一下,他按了两次取钱,此时创建了两个对账户减100的线程,正常情况下使用CAS的方式,线程1,线程2都对value进行读取,各自的oldvalue=200,线程1先执行扣款,判断oldvalue是否等于value,200=200,线程1执行成功后,账户余额由200变成了100(value=100),此时线程2再想进行扣款时,对比value和oldvalue,发现不相等,就不会执行扣款
但是异常的情况下,当线程1执行扣款之后,A的好兄弟B,给A账户转账了100,所以此时value=200,那当线程2执行的时候,判断oldvalue=value,所以线程2也执行了扣款操作,那100不就不翼而飞了吗
为了解决ABA问题,可以给要修该的值引入版本号,在CAS比较数据当前值value和旧值oldvalue时,也要对比版本号
CAS 操作在读取旧值的同时, 也要读取版本号.
真正修改的时候,
如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).
例如上面的例子,给A的账户余额添加一个版本号,初始值为1.线程1和线程2都会读取到版本号和value值,线程1先执行时,对于版本号是否相等,版本号相等,就会执行扣款操作,将value改为100,并且将版本号+1,此时版本号为2,当B给A转账后,value=200,版本号为3,此时当线程2执行时,对比value=oldvalue,但是版本号大于自己寄存器中读取到的版本号,就会执行失败。
JVM将synchronized锁分为无锁,偏向锁,轻量级锁,重量级锁状态。会自适应进行升级
偏向锁
第一个尝试加锁的线程,优先进入偏向锁状态,并不是真的加锁,而是给对象头中做一个"偏向锁的标记",记录这个锁属于哪个线程,但是并没有加锁,如果后续没有其他的线程来竞争这把锁,那就避免了加锁的开销,但是如果后续由其他的线程来竞争这个锁,由于第一个线程已经在对象头中做了"偏向锁的标记",所以这个线程就会率先加锁,取消偏向锁状态,进入轻量级锁状态。
偏向锁也可以理解为"延迟加锁",没有线程竞争锁,就不加锁,有线程竞争,就加锁。
轻量级锁
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现. 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
如果更新成功, 则认为加锁成功
如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.
因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了. 也就是所谓的 “自适应”
重量级锁
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
锁消除
编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除
锁粗化
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
锁的粒度:粗和细,粗粒度锁表示锁内代码较多,细粒度锁表示锁内代码较少,意味着代码持有锁的时间段,能更快的释放锁,其他线程冲突的概率也就更小
实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.
但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁.
例如
方式一:
打电话, 交代任务1, 挂电话.
打电话, 交代任务2, 挂电话.
打电话, 交代任务3, 挂电话.
方式二:
打电话, 交代任务1, 任务2, 任务3, 挂电话.
显然, 方式二是更高效的方案.
Callable也是一个创建线程的方式,Callable是一个interface,相当于把线程封装了一个"返回值",方便程序员借助多线程的方式计算结果
代码示例:创建线程计算1+2+3…+1000,不使用Callable
public class Test{
static class Result{
private int sum;
Object locker = new Object();
}
public static void main(String [] args){
Result result = new Result();
Thread t = new Thread(){
@Override
public void run(){
int sum = 0;
for(int i = 0; i <= 1000;i++){
sum = sum + i;
}
result.sum = sum;
synchronized(locker){
result.locker.notify();
}
}
};
t.start();
synchronized(locker){
while(result.sum == 0){
try{
result.locker.wait();
}catch(InterruptedException e){
e.printStackTrace();
}
}
}
System.out.println(result.sum);
}
}
上述代码需要一个辅助类 Result, 还需要使用一系列的加锁和 wait notify 操作, 代码复杂, 容易出错
使用Callable
public class Test{
public static void main(String [] args)throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable(){
@Override
public Integer call() throws Exception{
int sum = 0;
for(int i = 0 i <= 1000; i++){
sum = sum + i;
}
return sum;
}
};
//由于Thread不能直接传一个Callable类,所以需要一个辅助类来包装一下
FutureTask<Integer> futureTask = new FutureTask<>(callable);
//futureTask保存了callable返回的结果,此时callable大概率还没执行完,当callable执行完了之后,
//会把这个结果写入到FutureTask的实例中,
Thread t = new Thread(futureTask);
t.start();
Integer result = futureTask.get();
//如果futureTask中的结果还没生成,get方法就会阻塞等待,直到结果算出后,get才会返回
System.out.println(result);
}
}
ReentrantLock,是可重入互斥锁,和synchronized定位类似,都是用来实现互斥效果,保证线程安全
用法:
示例:
public class Test3 {
static class Counter{
private int count = 0;
ReentrantLock locker = new ReentrantLock();//创建reentrantlock实例
private void increase(){
locker.lock();//加锁
count++;
locker.unlock();//解锁
}
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(){
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
}
};
Thread t2 = new Thread(){
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
}
};
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(counter.count);
}
}
//使用reentrantlock对increase()方法中的count++操作进行加锁,也能保证线程安全,使t1,t2线程能分别自增50000次,保证最后的自增结果为100000
ReentrantLock和synchronized的区别
如何选择使用哪个锁呢?
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几种
以AtomicInteger为例,常见方法有:
addAndGet(int delta); //相当于i += delta;
decrementAndGet(); // --i;
getAndDecrement(); // i--;
incrementAndGet(); // ++i;
getAndIncrement(); // i++;
虽然创建销毁线程比创建销毁进程更轻量, 但是在频繁创建销毁线程的时候还是会比较低效. 线程池就是为了解决这个问题. 如果某个线程不再使用了, 并不是真正把线程释放, 而是放到一个 “池子” 中, 下次如果需要用到线程就直接从池子中取, 不必通过系统来创建了.
ExecutorService和Executors
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
Executors创建线程池的几种方式
Executors本质上是ThreadPoolExecutor类的封装
ThreadPoolExecutor
ThreadPoolExecutor 提供了更多的可选参数, 可以进一步细化线程池行为的设定.
ThreadPoolExecutor 的构造方法
信号量用来表示"可用资源的个数",本质上就是一个计数器,P操作就是申请资源,如果资源申请到了,信号量就减一,如果资源没申请到,也就是当信号量为0的时候,P操作就会阻塞等待,直到其他线程释放资源,V操作是释放资源,释放资源后信号量加一,PV操作都是原子的,所以Semaphor可以在多线程环境下直接使用
示例:
public class Test{
public static void main(String [] args){
Semaphore semaphore = new Semaphore(3);
Runnable runnable = new Runnable(){
@Override
public void run(){
try{
System.out.println("准备申请资源");
semaphore.acquire();
System.out.println("申请到了资源");
Thread.sleep(2000);
semaphore.release();
System.out.println("释放资源了");
}catch(InterruptedException e){
e.printStackTrace();
}
}
};
for(int i = 0; i < 20; i++){
Thread t = new Thread(runnable);
t.start();
}
}
}
同时等待N个任务执行结束,就好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。
示例:
public class Test {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(8);
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("起跑");
try {
//Math.random()随机创建[0,1)之间的浮点数
Thread.sleep((long) (Math.random()*10000));
} catch (InterruptedException e) {
e.printStackTrace();
}
latch.countDown();
System.out.println("冲线");
}
};
for (int i = 0; i < 8; i++) {
Thread t = new Thread(runnable);
t.start();
}
latch.await();
System.out.println("比赛结束");
}
}
HashMap本身是线程不安全的
只是把关键方法加上了synchronized关键字,相当于针对Hashtable对象本身加锁,当有两个线程同时访问Hashtable中任意数据都会发生锁竞争,而事实上Hashtable底层是一个数组,每个数组下标都连接了一个链表,可能多个线程访问的数据并不在同一个链表中,所以直接给hashtable本身加锁大大降低了代码的执行效率
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线
程被无限期地阻塞,因此程序不可能正常终止
下面列举几个出现死锁的场景:
//伪代码举例
void func1(){
synchronized(locker1){
synchronized(locker2){//此时线程1进入阻塞等待,等待线程2释放locker2
}
}
}
void func2(){
synchronized(locker2){//线程2进入阻塞等待,等待线程1释放locker1
synchronized(locker1){
}
}
}
多个线程多把锁(哲学家就餐)
假设有5个哲学家(相当于线程),5只筷子(锁),哲学家只有两个行为,思考和吃饭,思考时不用筷子,吃饭时需要拿起两只筷子才能吃饭,先拿起左边筷子,再拿起右边筷子,如果当有人想吃饭,但筷子被别人使用时,就会进入阻塞等待。
此时,如果5个哲学家都想要吃饭,并尝试拿起左边筷子,然后再拿起右边筷子,就发生问题了,每个哲学家右边的筷子都被别人占用着,所有就都进入了阻塞等待,此时就发生了死锁
死锁产生的四个必要条件
当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失,其中最容易破坏的就是"循环等待"
破坏循环等待
最常用的一种死锁阻止技术就是锁排序. 假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号(1, 2, 3…M).
N 个线程尝试获取锁的时候, 都按照固定的按编号由小到大顺序来获取锁. 这样就可以避免环路等待
//约定好先获取 lock1, 再获取 lock2 , 就不会环路等待
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {
// do something...
}
}
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {
// do something...
}
}
}
例如哲学家就餐问题: