07.JAVAEE之线程5

与面试相关

1.常见的锁策略

不是一把具体的锁,而是一类锁

1.1 乐观锁 vs 悲观锁

悲观乐观,是对后续锁冲突是否激烈(频繁)给出的预测

如果预测接下来锁冲突的概率不大,就可以少做一些工作. 就称为 乐观锁

如果预测接下来锁冲突的概率很大,就应该多做一些工作. 就称为 悲观锁

1.2 重量级锁 vs 轻量级锁

  • 轻量级锁,锁的开销比较小
  • 重量级锁,锁的开销比较大.
  • 和刚才的乐观悲观是有关联的,
  • 一个是预测锁冲突的概率,,一个是实际消耗的开销

1.3 自旋锁和挂起等待锁

此处的忙等带来了更高的效率。

1.4 读写锁

把加锁,分成两种了:

读加锁:读的时候, 能读, 但是不能写

写加锁:读的时候,不能读, 也不能写

两个线程加锁过程中:

1.读锁和读锁之间, 不会产生竞争(多线程读同一个东西,有线程安全)

2.读锁和写锁之间,有竞争

3.写锁和写锁之间, 也有竞争

实际开发中,遇到的场景,往往是"读多,写少"

1.5 可重入锁 vs 不可重入锁

一个线程针对同一把锁,连续加锁两次,不会死锁, 就是可重入锁,会死锁, 就是不可重入锁

1.6 公平锁 vs 非公平锁

当很多线程去尝试加一把锁的时候,一个线程能够拿到锁,其他线程阻塞等待一旦第一个线程释放锁之后,接下来是哪个线程能够拿到锁呢?
公平锁: 就是按照"先来后到"顺序.

非公平锁: 则是剩下的线程以"均等"的概率,来重新竞争锁,

操作系统提供的加锁 apì 默认情况, 就属于"非公平锁"如果要想实现公平锁,你还需要引入额外的队列,维护这些线程的加锁顺序

1.7 Synchronized属于那种锁呢???

  1. 对于"悲观乐观"自适应的,
  2. 对于"重量轻量"自适应的.
  3. 对于"自旋挂起等待"自适应的,
  4. 不是读写锁
  5. 是可重入锁
  6. 是非公平锁
  • 什么是自适应呢???
  • 初始情况下,synchronized 会预测当前的锁冲突的概率不大,此时以乐观锁的模式来运行.(此时也就是轻量级锁,基于自旋锁的方式实现)
    在实际使用过程中,如果发现锁冲突的情况比较多。synchronized 就会升级成 悲观锁 (也就是重量级锁, 基于挂起等待的方式实现)

2.CAS

2.1 什么是CAS

Compare and Swap比较和交换寄存器

2.2 CAS 伪代码

下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解

CAS 的工作流程.

java 复制代码
boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
   &address = swapValue;
        return true;
   }
    return false;
}

CAS 其实是一个 cpu 指令

(一个 cpu 指令,就能完成上述比较交换的逻辑)

单个的 cpu 指令,是原子的!! 就可以使用 CAS 完成一些操作(给编写线程安全的代码,引入了新的思路~~),进一步的替代"加锁"

基于 CAS 实现线程安全的方式,也称为"无锁编程

优点: 保证线程安全, 同时避免阻塞.(效率)缺点:

1.代码会更复杂,不好理解

2.只能够适合一些特定场景, 不如加锁方式更普适,

CAS 本质上是 cpu 提供的指令 =>又被操作系统封装,提供成 api =>又被 JVM 封装,也提供成 api =>程序员使用了

2.3 CAS 是怎么实现的

针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:

  • java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
  • unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;
  • Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性。

简而言之,是因为硬件予以了支持,软件层面才能做到。

2.4 CAS 有哪些应用

1) 实现原子类

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.

典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.

java 复制代码
import java.util.concurrent.atomic.AtomicInteger;

public class Demo29 {
    public 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--
                // count.getAndDecrement();
                // --count
                // count.decrementAndGet();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count.get());
    }
}

结论: 原子类里面是基于 CAS 来实现的

伪代码的实现:

前面说"线程不安全"本质上是进行自增的过程中,穿插执行了

CAS 也是让这里的自增,不要家插执行,核心思路和加锁是类似的.

  • 加锁是通过阻塞的方式,避免穿插.
  • CAS 则是会通过重试的方式,避免穿插.

2) 实现自旋锁

基于 CAS 实现更灵活的锁, 获取到更多的控制权

伪代码

java 复制代码
public class SpinLock {
    private Thread owner = null;
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有. 
        // 如果这个锁已经被别的线程持有, 那么就自旋等待. 
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. 
        while(!CAS(this.owner, null, Thread.currentThread())){
       }
   }
    public void unlock (){
        this.owner = null;
   }
}

缺点:会消耗CPU。

CAS 也是多线程编程中的一种重要技巧,虽然开发中直接使用 CAS 的概率不大,但是经常会用到一些 内部封装了 CAS 的操作.

2.5 CAS 的 ABA 问题

CAS 进行操作的关键,是通过 值"没有发生变化"来作为"没有其他线程穿插执行" 判定依据

但是,这种判定方式,不够严谨.

更极端的情况下,可能有另一个线程穿插进来,把值从 A ->B ->A

针对第一个线程来说,看起来好像是这个值,没变,但是实际上已经被穿插执行了

一般来说ABA这样没有什么问题

但是存在极端问题

假设这个场景,我去 ATM 取钱. 我本身的 账户 1000我想要取 500

我在取钱的过程中, 出现 bug 了.

我按下取钱按钮, 没反应,我又按了一下.此时就产生了两个线程进行扣款操作!!

未涉及ABA时

加入t3线程(别人同时进行转钱操作,即就会出现bug)

2.5.1 ABA问题的解决方案

只要让判定的数值,按照一个方向增长即可,(不要反复横跳)有增有减,就可能出现 ABA只是增加,或者只是减少;

针对像账户余额这样的概念,本身就应该要能增能减

可以引入一个额外的变量,版本号.

约定每次修改余额,都要让版本号自增.

此时在使用 CAS 判定的时候,就不是直接判定余额了,而是判定版本号,看版本号是否是变化了如果版本号不变,注定没有线程穿插执行了

3.Synchronized原理

1.基本特点

结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):

  1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.

  2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.

  3. 实现轻量级锁的时候大概率用到的自旋锁策略

  4. 是一种不公平锁

  5. 是一种可重入锁

  6. 不是读写锁

2.synchronized 几个重要的机制

  • 锁升级
  • 锁消除
  • 锁粗化

2.1 锁升级

JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁状态。会根据情况,进行依次升级。(是单向的不能降级)

偏向锁不是真正的加锁,只是进行了一个标记。

偏向锁的核心思想, 我们以前讲过, 就是"懒汉模式"另一种体现(没有线程竞争,就不加锁,有别的线程竞争,就抢先一步进行加锁)

能不加锁, 就尽量不加锁.加锁意味着有开销

2.2 锁消除

锁消除.也是一种编译器优化的手段

编译器会自动针对你当前写的 加锁的代码,做出判定,

如果编译器觉得这个场景,不需要加锁,此时就会把你写的synchronized 给优化掉.

  • StringBuilder不带 synchronized
  • StringBuffer带有 synchronized
  • 如果是在单个线程中使用 StringBuffer,此时编译器就会自动的把 synchronized 给优化掉编译器只会在自己非常有把握的时候,才会进行锁消除~~(触发的概率不算很高)

2.3 锁粗化

锁的粒度.

synchronized 里头,代码越多,就认为锁的粒度越粗代码越少,锁的粒度越细

粒度细的时候,能够并发执行的逻辑更多,更有利于充分利用多核 CPU 资源

但是,如果粒度细的锁,被反复进行加锁解锁,可能实际效果还不如粒度粗的锁.(涉及到反复的锁竞争)

3.JUC常见类

3.1.Callable

Callable 是一个 interface . 相当于把线程封装了一个 "返回值". 方便程序猿借助多线程的方式计算结果.

也是一种创建线程的方式,

适合于,想让某个线程执行一个逻辑,并且返回结果的时候。相比之下, Runnable 不关注结果

java 复制代码
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class Demo30 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 定义了任务.
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 0; i <= 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };
        // 把任务放到线程中进行执行.
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();

        // 此处的 get 就能获取到 callable 里面的返回结果.
        // 由于线程是并发执行的. 执行到主线程的 get 的时候, t 线程可能还没执行完.
        // 没执行完的话, get 就会阻塞.
        System.out.println(futureTask.get());
    }
}

//call()这个是 Callable 中的核心方法返回值就是 Integer, 期望这个线程能够返回一个整数,
小结: 线程的创建方式

1.继承 Thread, 重写 run(创建单独的类,也可以匿名内部类)

2.实现 Runnable, 重写 run(创建单独的类, 也可以匿名内部类3. 实现 Callable, 重写 cal (创建单独的类, 也可以匿名内部类)

  1. 使用 lambda 表达式

5.ThreadFactory 线程工厂

6.线程池

3.2 ReentrantLock (Reentrant可重入)

可重入互斥锁 . 和 synchronized 定位类似 , 都是用来实现互斥效果 , 保证线程安全 .

ReentrantLock lock = new ReentrantLock ();

lock . lock ();
try {
// working
} finally {
lock.unlock() //不能忘记此操作
}
优势:

1..ReentrantLock,在加锁的时候,有两种方式.lock, tryLock.

给了咱们更多的可操作空间

2.ReentrantLock,提供了 公平锁 的实现.(默认情况下是非公平锁)

3.ReentrantLock 提供了更强大的等待通知机制. 搭配了 Condition 类,实现等待通知的

虽然 ReentrantLock 有上述优势,但是咱们在加锁的时候,还是首选:synchronized

但是很明显, ReentrantLock 使用更加复杂,尤其是容易忘记解锁

另外 synchronized 背后还有一系列的优化手段~~

3.3 信号量 semaphore

也是操作系统课程中,比较重要的概念,

信号量, 就是一个计数器,描述了"可用资源"的个数

每次申请一个可用资源,就需要让计数器-1->P操作 acquire

操作每次释放一个可用资源,就需要让计数器 +1->V 操作 release

(这里的 +1和-1 都是原子的)
信号量,假设初始情况下 数值是 10

每次进行 P操作,数值就 -1

当我已经进行了 10 次 P操作之后,数值就是 0了

如果我继续进行 P操作,会咋样呢?=>阻塞等待!!

java 复制代码
import java.util.concurrent.Semaphore;

public class Demo31 {
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(4);
        semaphore.acquire();
        System.out.println("P 操作");
        semaphore.acquire();
        System.out.println("P 操作");
        semaphore.acquire();
        System.out.println("P 操作");
        semaphore.acquire();
        System.out.println("P 操作");
        semaphore.acquire();
        System.out.println("P 操作");
        // semaphore.release();
    }
}

Semaphore semaphore=new semaphore(4);

开发中如果遇到了需要申请资源的场景,就可以使用信号量来实现了,

3.4 CountDownLatch

这个东西,主要是适用于,多个线程来完成一系列任务的时候,用来衡量任务的进度是否完成比如需要把一个大的任务,拆分成多个小的任务,让这些任务并发的去执行,就可以使用 countDownLatch 来判定说当前这些任务是否全都完成了

CountDownLatch 主要有两个方法.

1.await,调用的时候就会阻塞,就会等待其他的线程完成任务,所有的线程都完成了任务之后,此时这个 await 才会返回, 才会继续往下走.

2.countDown,告诉countDownLatch,我当前这一个子任务已经完成了

java 复制代码
public class Demo32 {
    public static void main(String[] args) throws InterruptedException {
        // 10 个选手参赛. await 就会在 10 次调用完 countDown 之后才能继续执行.
        CountDownLatch countDownLatch = new CountDownLatch(10);
        for (int i = 0; i < 9; i++) {
            int id = i;
            Thread t = new Thread(() -> {
                System.out.println("thread " + id);
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                // 通知说当前的任务执行完毕了.
                countDownLatch.countDown();
            });
            t.start();
        }
        // a => all
        countDownLatch.await();
        System.out.println("所有的任务都完成了. ");
    }
}

4.线程安全的集合类

数据结构中大部分的集合类,都是线程不安全的.
Vector,Hashtable ,(前面两个是上古时期遗留的产物,现在已经不建议使用了)Stack, 线程安全 =>(加了synchronized)

针对这些线程不安全的集合类,要想在多线程环境下使用, 就需要考虑好 线程安全问题了(核心问题是加锁)

同时,标准库,也给我们提供了一些搭配的组件,保证线程安全

Collections.synchronizedList(new ArrayList);

这个东西会返回一个新的对象,这个新的对象,就相当于给 ArrayList 套了一层壳这层壳就是在方法上直接使用 synchronized 的

CopyOnWriteArrayList写时拷贝

比如,两个线程使用同一个 ArrayList可能会读,也可能会修改.
如果要是两个线程读,就直接读就好了

如果要是两个线程读,就直接读就好了.

如果某个线程需要进行修改,就把 ArrayList, 复制出一份副本.修改线程,就修改这个副本.与此同时,另一个线程仍然可以读取数据(从原来的数据上进行读取)

一旦这边修改完毕,就会使用修改好的这份数据,替代掉原来的数据,(往往就是一个引用赋值)

1.当前操作的 ArrayList 不能太大.(拷贝成本, 不能太高)

2.更适合于一个线程去修改,而不能是多个线程同时修改,(多个线程读,一个线程修改)

这种场景特别适合于服务器的配置更新~~可以通过配置文件,来描述配置的详细内容.(本身就不会很大)

配置的内容会被读到内存中,再由其他的线程,读取这里的内容但是修改这个配置内容,往往只有一个线程来修改,

使用某个命令让服务器重新加载配置,就可以使用写时拷贝的方式了

ConcurrentHashMap

Hashtable 保证线程安全,主要就是给关键方法,加上 synchronized.直接加到方法上的.(相当于给 this加锁)

只要两个线程,在操作同一个 Hashtable 就会出现锁冲突~~

但是,实际上,对于哈希表来说,锁不一定非得这么加,有些情况,其实是不涉及到线程安全问题的~~

//两个不同的 key 映射到同一个数组下标上,就会出现hash 冲突.

(数组+链表避免hash冲突,此时是否会发生线程安全)

按照上述这样的方式来操作,并且在不考虑 触发扩容 的前提下操作不同的链表的时候就是线程安全的.

如果两个线程,操作的是不同的链表,就根本不用加锁.只有说操作的同一个链表才需要加锁.

1.ConcurrentHashMap 最核心的改进,就是把一个全局的大锁,改进成了 每个链表独立的一把小锁.
这样做,大幅度降低了锁冲突的概率.
1个 hash 表,有很多这样的链表.两个线程恰好同时访问一个 链表 情况,本身就比较少

**2.**充分利用到了 CAS 特性,把一些不必要加锁的环节给省略加锁了

比如,需要使用变量记录 hash 表中的元素个数此时,就可以使用原子操作(CAS) 修改元素个数

3.ConcurrentHashMap, 还有一个激进的操作,针对读操作没有加锁,读和读之间.读和写之间, 都不会有锁竞争.(读和写之间有加锁)

  • 是否会存在这种"读到一个 修改了一半 的数值呢?ConcurrentHashMap 在底层编码过程中,比较谨慎的处理了一些细节修改的时候会避免使用 ++-- 这种非原子的操作!使用 =进行修改,本身就是原子的.
  • 读的时候,要么读到的是写之前的旧值,要么是读到写之后的新值,不会出现读到一个 一半的值

4.ConcurrentHashMap 针对扩容操作, 做出了单独的优化.

本身 Hashtable 或者 HashMap 在扩容的时候,都是需要把所有的元素都拷贝一遍的(如果元素很多,拷贝就比较耗时)
用户访问 1000 次,999 次都很流畅,其中有一次就 卡了.(正好这一次触发扩容,导致出现卡顿)

【解决方案】化整为零
一旦需要扩容,确实需要搬运,不是在一次操作中搬运完成,而是分成多次,来搬运,每次只搬运一部分数据避免这单次操作过于卡顿
ConcurrentHashMap 基本的使用方法和普通的 HashMap 完全一样

相关推荐
杨DaB7 分钟前
【SpringBoot】Swagger 接口工具
java·spring boot·后端·restful·swagger
YA3338 分钟前
java基础(九)sql基础及索引
java·开发语言·sql
桦说编程28 分钟前
方法一定要有返回值 \ o /
java·后端·函数式编程
小李是个程序1 小时前
登录与登录校验:Web安全核心解析
java·spring·web安全·jwt·cookie
David爱编程1 小时前
Java 创建线程的4种姿势,哪种才是企业级项目的最佳实践?
java·后端
hrrrrb2 小时前
【Java Web 快速入门】十一、Spring Boot 原理
java·前端·spring boot
Java微观世界2 小时前
Object核心类深度剖析
java·后端
MrSYJ2 小时前
为什么HttpSecurity会初始化创建两次
java·后端·程序员
hinotoyk2 小时前
TimeUnit源码分享
java
AAA修煤气灶刘哥3 小时前
Java+AI 驱动的体检报告智能解析:从 PDF 提取到数据落地全指南
java·人工智能·后端