多线程进阶

1. 常见锁策略1.1 乐观锁和悲观锁1.2 轻量级锁和重量级锁1.3 自旋锁和挂起等待锁synchronized具有自适应能力1.4 普通互斥锁和读写锁1.5 公平锁和非公平锁1.6 可重入锁和不可重入锁

2. Synchronized原理(特点、加锁过程、自适应)2.1 Synchronized基本特点2.2 Synchronized加锁过程2.3 锁消除2.4 锁粗化

3. CAS(compare and swap)3.1 CAS 的ABA问题怎么解决

4. Callable接口5. JUC(java.util.concurrent)的常见类5.1 ReentrantLock

6. 信号量Semaphore6.1 使用Semaphore可以保证线程安全,就相当于synchronized加锁

7. CountDownLatch8. ConcurrentHashMap8.1 ConcurrentHashMap(Java1.8)的改进

1. 常见锁策略

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

1.1 乐观锁和悲观锁

乐观锁:在加锁之前,预估当前出现锁冲突的概率不大,因此在进行加锁的时候就不会做太多的工作,加锁的过程中做的事情比较少,加锁的速度比较快,但是更容易引入一些其他的问题,可能会消耗更多的cpu资源。悲观锁:在加锁之前,预估当前锁冲突出现的概率比较大,因此在加锁的时候做更多的工作,做的事情更多,加锁的速度可能更慢,但是整个过程中不容易出现其他问题。乐观锁的实现:引入一个版本号,借助版本号识别出当前的数据访问是否冲突。悲观锁的实现:先加锁(比如借助操作系统提供的mutex),获取到锁再操作数据,获取不到就等待。

1.2 轻量级锁和重量级锁

按加锁开销的大小分:

轻量级锁:加锁的开销小,加锁的速度更快,轻量级锁一般就乐观锁。重量级锁:加锁的开销大,加锁的速度更慢,重量级锁一般是悲观锁。

轻量和重量是加锁之后对结果的评价,乐观和悲观是加锁之前,对未发生的事情进行的预估,整体来说,这两种角度描述的是同一个事情。

1.3 自旋锁和挂起等待锁

自旋锁:是轻量级锁的一种典型实现,进行加锁的时候,搭配一个while循环,如果加锁成功,自然循环结束。如果加锁不成功,不是阻塞放弃cpu,而是进行下一次循环,再次尝试获取到锁。这个反复快速执行的过程就称为自旋。一旦其他线程释放了锁,就能第一时间拿到锁。同时这样的自旋锁也就是乐观锁。自旋的前提:就是预期冲突的概率不大,其他线程释放了锁,就能第一时间拿到锁。当加锁的线程很多,自旋的意义就不大了,会浪费CPU资源。挂起等待锁:重量级锁的一种典型实现,进行等待挂起等待的时候,就需要内核调度器介入,这一块要完成的操作很多,真正获取到锁花的时间就更多。同时也是一种悲观锁,这个锁可以适用于锁冲突激烈的情况。

synchronized具有自适应能力

如果当前锁冲突的激烈程度不大,就处于乐观锁/轻量级锁/自旋锁。如果当前锁冲突的激烈程度很大,就处于悲观锁/重量级锁/挂起等待锁。

1.4 普通互斥锁和读写锁

一个线程对于数据的访问,主要存在两种操作:读数据和写数据。

两个线程都只是读一个数据,此时并没有线程安全问题,之间并发的读取即可。两个线程都要写一个数据,有线程安全问题。一个线程读另外一个线程写,有线程安全问题。

普通互斥锁:多线程之间,数据的读取时之间不会产生线程安全问题,但是数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗,所以引出读写锁。读写锁:在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。读写锁分为两种情况,加 读锁和加 写锁。

读锁和读锁之间,不会出现锁冲突(不会阻塞,即不互斥)。写锁和写锁之间,会出现锁冲突(会阻塞,即互斥)。读锁和写锁之间,会出现锁冲突(会阻塞,即互斥)。

一个线程加 读锁的时候,另一个线程只能读,不能写。一个线程加 写锁的时候,另一个线程,不能读也不能写。

1.5 公平锁和非公平锁

假如三个线程A,B,C,A先尝试获取锁,获取成功,然后B再尝试获取锁,获取失败,阻塞等待;然后C也尝试获取锁,C也获取失败,阻塞等待。

公平锁:遵守 “先来后到”,当A释放锁,B先于C拿到锁。非公平锁:B和C都有可能获取到锁。 Java中synchronized是非公平锁。

1.6 可重入锁和不可重入锁

可重入锁:一个线程针对一把锁,连续两次加锁,不会死锁。synchronized是可重入锁,实现方式:在加锁中记录锁持有的线程身份,以及一个计数器(记录加锁次数),如果发现加锁的线程就是持有锁的线程,则直接计数自增。不可重入锁:一个线程针对一把锁,连续两次加锁,会死锁。系统自带的锁是不可重入的锁。

2. Synchronized原理(特点、加锁过程、自适应)

2.1 Synchronized基本特点

开始是乐观锁,如果锁冲突频繁,转换为悲观锁(自适应)开始是轻量级锁实现,如果锁被持有的时间较长,就转换为重量级锁(自适应)实现轻量级锁的时候大概率用到自旋锁策略(自适应)是一种不公平锁是一种可重入锁不是读写锁

2.2 Synchronized加锁过程

当线程执行到Synchronized的时候,如果这个对象当前处于为加锁的状态,就会经历以下过程:

偏向锁阶段:假设没有线程竞争。 核心思想就是懒汉模式,能不加锁就不加锁,能晚加锁就晚加锁。所谓的偏向锁,并非真的加锁了,而只是做了一个非常轻量的标记。非必要不加锁,在遇到竞争的情况下,偏向锁没有提高效率,但如果实在没有竞争的情况下,偏向锁就大幅度提高了效率。轻量级锁阶段:假设有竞争,但是不多。此处就是通过自旋锁的方式来实现的;优势:另外的线程把锁释放了,就会第一时间拿到锁。劣势:比较消耗cpu,即对于自旋锁来说,如果同一个锁竞争者很多,大量线程都在自旋,整体cpu的消耗就很大。此同时,synchronized内部会统计当前这个锁对象上,有多少个线程在参与竞争,当参与竞争的线程多了就会进一步升级到重量级锁。重量级锁阶段:锁竞争进一步激烈,此处的重量级锁就是指用到内核提供的mutex,此时拿不到锁的线程就不会继续自旋了,而是进入阻塞等待,让出cpu,当当前线程释放锁的时候,就由系统随机唤醒一个来获取锁。

2.3 锁消除

锁消除也是synchronized中内置的优化策略。编译器优化的一种方式,编译器编译代码的时候,如果发现这个代码,不需要加锁,就会自动把锁给干掉。针对一眼看上去就不涉及安全问题的代码,能够把锁消除掉,对于其他的很多模棱两可的,都不会消除。

2.4 锁粗化

锁粗化,会把多个细粒度的锁,合并成一个粗粒度的锁。粒度指的是synchronized {}里大括号里面包含代码越少,就认为锁的粒度越细,包含的代码越多,就认为锁的粒度越粗。 通常情况下,是更偏于让锁的粒度细一些,更有利于多个线程并发执行的,但有时候也希望粒度粗点好. 如A给B交代任务,打电话,交代任务1,挂电话。打电话,交代任务2,挂电话。打电话,交代任务3,挂电话。粗化成,打电话,交代任务1,2,3.挂电话。把这三个合并一个粗粒度的锁,粗化提高了效率。

小结:synchronized的优化操作

锁升级:偏向锁 -> 轻量级锁 -> 重量级锁锁消除:自动干掉不必要的锁锁粗化:把多个细粒度的锁合并成一个粗粒度的锁,减少锁竞争。

3. CAS(compare and swap)

CAS是一个特殊的cpu的指令,完成的工作就是 比较 和 交换。是单个cpu指令,本身是原子的,同时完成 读取内存,比较是否相等,修改内存。

CAS的伪代码: 比较address内存地址中的值,是否和expected寄存器中的值相同,如果相同,就把swap寄存器的值和address内存的值,进行交换,返回true。如果不相同,直接返回false。

CAS本身是cpu指令,操作系统对指令进行了封装,jvm又对操作系统提供的api又封装了一层,有的cpu可能会不支持cas,而Java的标准库,对于CAS又进行了进一步的封装,提供了一些工具类,里面最主要的一个工具,原子类。之前的线程安全都是靠加锁,加锁-》阻塞-》性能降低。基于CAS指令,不涉及加锁,不会阻塞,合理使用也保证线程安全,即无锁编程。

public class ThreadDemo34 {

// AtomicInteger: Java标准库中,对cas又进一步的封装,提供了一些工具类,即原子类

// 使用原生的int 会出现线程安全问题 ,不使用加锁,使用AtomicInteger替换int也能保证线程安全

// private static int count = 0;

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++) {

//getAndIncrement 对变量的修改,是一个cas指令,即这个指令天然就是原子的

//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);

}

}

3.1 CAS 的ABA问题怎么解决

假如去ATM取钱,里面有1000,要取500,取钱的时候ATM卡了,按了一下没反应(t1线程),又按了一下(t2线程),此时此时产生了两个线程,去尝试扣款操作,此处 假如按CAS的方式扣款,这样是没问题的。当又来个t3线程给账户存了500,此时t1线程就不知道当前的1000是始终没变还是变了又变回来了。

解决方案:

约定数据变化是单向的(只能增加或者只能减少),不能是双向的(即又增加又能减少)。对于本身就必须双向变化的数据,可以给它引入一个版本号,版本号这个数字是只能增加,不能减少。

4. Callable接口

继承Thread(包含了匿名内部类的方式)实现Runnable(包含了匿名内部类的方式)基于lambda基于Callable基于线程池

Runnable关注的是这个过程,不关注执行结果,Runnable提供的是run方法,返回值类型是void

public class ThreadDemo35 {

private static int sum = 0;

public static void main(String[] args) throws InterruptedException {

// 创建一个新线程,用新的线程实现从1+到1000

// 不用callable

Thread t = new Thread(new Runnable() {

@Override

public void run() {

int result = 0;

for (int i = 0; i <= 1000; i++) {

result += i;

}

sum = result;

}

});

t.start();

t.join();

// 主线程获取到计算结果

// 此处想要获取到结果,就需要弄一个成员变量保持上述结果

System.out.println("sum= " + sum);

}

}

Callable要关注执行结果,Callable提供的call方法,返回值就是线程执行任务得到的结果callable 通常搭配FutureTask来使用,FutureTask用来保存Callable的返回结果,因为callable往往是在另一个线程中执行的,什么时候执行完不确定。FutureTask类,作为Thread和callable的粘合剂,使用futureTask.get()获取结果,带有阻塞功能,如果线程还没有执行完,get就会阻塞,等待线程执行完。 当编写多线程代码,希望关注线程中代码的返回值的时候: 相比于runnable来说,不需要引入额外的成员变量,直接借助这个的返回值即可。

import java.util.concurrent.*;

// Callable 接口

public class ThreadDemo36 {

public static void main(String[] args) throws ExecutionException, InterruptedException {

// 期望线程的入口方法里,返回值是啥类型,此处的泛型就是什么类型 这里希望返回值是Integer

Callable callable = new Callable() {

@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 futureTask = new FutureTask<>(callable);

Thread t = new Thread(futureTask);

t.start();

// 接下来这个代码不需要join,使用futureTask获取到结果

// futureTask.get() 这个操作也具有阻塞功能,如果线程还没有执行完毕,get就会阻塞,等到线程执行完了,

//return的结果就会被get返回回来

System.out.println(futureTask.get());

}

}

5. JUC(java.util.concurrent)的常见类

5.1 ReentrantLock

用法:

lock() :加锁,如果获取不到锁就死等unlock(),解锁trylock(超出时间):加锁,如果获取不到锁,等待一定的时间之后就放弃加锁。 ReentrantLock,可重入锁,和synchronized一样是可重入锁,有了synchronized为什么还需要ReentrantLock:

ReentrantLock提供了tryLock操作,lock直接进行加锁,如果加锁不成,就要阻塞;tryLock,尝试进行加锁,如果加锁成功,不阻塞,直接返回false,即tryLock提供了更多的可操作空间。Reentrant Lock提供了公平锁的实现(通过队列记录加锁线程的先后顺序),ReentrantLock构造方法中填写参数,就可以设置公平锁,而synchronized是非公平锁。搭配的等待通知机制不同的,synchronized,搭配wait / notify,对于Reentrant Lock,搭配Condition类,功能比wait / notify 略强一点。

6. 信号量Semaphore

信号量,用来表示 可用资源的个数,本质上就是一个计数器。

import java.util.concurrent.Semaphore;

// 信号量 Semaphore

public class ThreadDemo37 {

public static void main(String[] args) throws InterruptedException {

Semaphore semaphore = new Semaphore(1);

// 申请1个资源

semaphore.acquire();

System.out.println("P 操作");

semaphore.acquire();

System.out.println("P 操作");

// 释放一个资源

semaphore.release();

System.out.println("V 操作");

}

}

new一个semaphore对象,有1个可用资源semaphore.acquire :申请一个资源,资源个数 -1(P操作)semaphore.release :释放一个资源,资源个数 +1 (V操作)如果计数器为0,即没有可用资源了,还申请资源,就会阻塞等待,直到有其他线程释放资源。Semaphore的PV操作的加减操作都是原子的,可以在多线程下直接使用。

所谓的锁,本质上也是一种特殊的信号量,锁,可以认为计数值为1的信号量,释放状态,就是1,加锁状态,就是0,对于非0即1的信号量,称为二元信号量,信号量是更广义的锁。

6.1 使用Semaphore可以保证线程安全,就相当于synchronized加锁

使用Semaphore,先申请一个资源然后进行下述count++操作,再进行释放操作,这样也可以确保线程安全。

import java.util.concurrent.Semaphore;

// 信号量 Semaphore

// 在操作前先申请一个可用资源 使数字-1 semaphore.acquire(); 后semaphore.release(); 数字+1 释放一个可用资源

// 加锁状态, 就是 0 ,释放状态,就是1 对于非0即1的信号量就称为 二元信号量

public class ThreadDemo38 {

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) {

e.printStackTrace();

}

count++;

semaphore.release();

}

});

Thread t2 = new Thread(() -> {

for (int i = 0; i < 50000; i++) {

try {

semaphore.acquire();

} catch (InterruptedException e) {

e.printStackTrace();

}

count++;

semaphore.release();

}

});

t1.start();

t2.start();

t1.join();

t2.join();

System.out.println(count);

}

}

小结 确保线程安全的操作:

synchronizedReentrantLockCAS(原子类)Semaphore

7. CountDownLatch

CountDownLatch,同时等待N个任务执行结束,比如,多线程执行一个任务,把大的任务拆成几个部分,由每个线程分别执行。

构造CountDownLatch实例,初始化10表示10个任务线程需要完成每个任务执行完毕,都调用latch.countDown(),在CountDownLatch 内部的计数器同时自减主线程中使用latch.await(),阻塞等待所有任务结束,相当于计数器为0

** join() ,就只能每个线程执行一个任务,而使用CountDownLatch就可以一个线程执行多个任务**。

public class ThreadDemo39 {

public static void main(String[] args) throws InterruptedException {

// 1.此处构造方法中写10,意思是有10个线程任务

CountDownLatch latch = new CountDownLatch(10);

// 创建出 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) {

e.printStackTrace();

}

System.out.println("线程结束 " + id + "结束下载");

// 2.告知 CountDownLacth 执行结束了

latch.countDown();

});

t.start();

}

// 3. 通过await操作等待所有任务结束,也就是 countDown被调用10次

latch.await();

System.out.println("所有任务都已经完成了");

}

}

8. ConcurrentHashMap

多线程环境下HashMap线程不安全,使用哈希表(Hashtable)就在关键方法上添加synchronized。

ConcurrentHashMap的读是否要加锁? 读操作没有加锁,目的是为了进一步降低锁冲突的概率,为了保证读到刚修改的数据,搭配了volatile关键字。 介绍ConcurrentHashMap的锁分段技术? 把若干个哈希桶分成一个段,针对每个段分别加锁,这个是Java1.7中采取的技术,Java1.8不再使用,

8.1 ConcurrentHashMap(Java1.8)的改进

缩小了锁的粒度 在Hashtable,直接在方法上使用synchronized,就相当于是对this加锁,此时尝试修改两个不同链表上的元素,都会触发锁冲突。如果修改两个不同链表上的元素,就不涉及到线程安全,修改不同变量。如果修改是同一个链表上的元素,就可能出现线程安全问题。 ConcurrentHashMap 就是把锁变小了,给每个链表都发了一把锁,此时,不是操作同一个链表的锁,就不会产生锁冲突。 不会产生更多的空间代价,因为Java中任何一个对象都可以直接作为锁对象,本身哈希表中,就得有数组,数组的元素都是已经存在的(每个链表的头节点作为加锁对象即可)。 锁桶(hash表也称为hash桶),构成了一个类似于桶,每个链表就是构成桶的一个木板,所谓锁桶就是针对每个木板(每个链表)进行分别加锁的。 充分的使用了CAS原子操作,减少一些加锁 针对扩容操作的优化:扩容是一个重量操作。负载因子,描述了每个桶上平均有多少个元素,0.75是负载因子默认的扩容阈值,不是负载因子本体。负载因子是计算出来的:拿着实际的元素个数 / 数组的长度(桶的个数),这个值和扩容阈值进行比较,看是否需要扩容。

如果太长,1.变成树(长度不平均的情况),2.扩容。创建一个更大的数组,把旧的hash表的元素都给搬运到(删除/插入)新的数组上,如果hash表本身元素非常多,这里扩容操作就会消耗很长的时间。** HashMap的扩容操作是梭哈,在某次插入元素的操作中,整体就进行了扩容。而ConcurrentHashMap,每次操作都只搬运一部分元素。**即在扩容的过程中,同时存在两份哈希表,一份新的一份旧的:插入操作:直接往新的上插入;删除操作:新的旧的都是直接删除;查找操作:新的和旧的都得查询一下。

推荐文章

评论可见,请评论后查看内容,谢谢!!!
 您阅读本篇文章共花了: