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 完全一样

相关推荐
芒果披萨2 分钟前
El表达式和JSTL
java·el
duration~42 分钟前
Maven随笔
java·maven
zmgst1 小时前
canal1.1.7使用canal-adapter进行mysql同步数据
java·数据库·mysql
跃ZHD1 小时前
前后端分离,Jackson,Long精度丢失
java
blammmp1 小时前
Java:数据结构-枚举
java·开发语言·数据结构
暗黑起源喵2 小时前
设计模式-工厂设计模式
java·开发语言·设计模式
WaaTong2 小时前
Java反射
java·开发语言·反射
九圣残炎2 小时前
【从零开始的LeetCode-算法】1456. 定长子串中元音的最大数目
java·算法·leetcode
wclass-zhengge2 小时前
Netty篇(入门编程)
java·linux·服务器
Re.不晚3 小时前
Java入门15——抽象类
java·开发语言·学习·算法·intellij-idea