多线程进阶

目录

锁的策略

第一组:乐观锁和悲观锁

第二组:轻量级锁和重量级锁

第三组:自旋锁和挂起等待锁

第四组:普通互斥锁和读写锁

第五组:公平锁和非公平锁

第六组:可重入锁和不可重入锁

synchronized的锁策略

锁升级

1.偏向锁阶段

2.轻量级锁阶段

3.重量级锁阶段

锁消除

锁粗化

CAS

CAS的线程安全

CAS的ABA问题

其他小知识

Callable接口:创建线程的第四种方式

可重入锁:ReentrantLock

信号量 Semaphore

CountDownLatch

多线程环境使用哈希表


锁的策略

加锁时,处理冲突的过程中会涉及到一些不同的处理方式

第一组:乐观锁和悲观锁

乐观锁:在加锁之前,预估出现锁冲突的概率并不大,因此在进行加锁的时候就不会做太多的工作

做的事情少,加锁速度会变快,但更容易引入一些其他问题(比如消耗更多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的扩容操作

每次操作只搬运一部分元素,虽然可能搬运次数会比较多,花的时间比较长,但是每次操作的时间比较短而且易控制

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值