并发编程学习(1)

您所在的位置:网站首页 microsoftime占cpu很高 并发编程学习(1)

并发编程学习(1)

2023-04-01 11:15| 来源: 网络整理| 查看: 265

并发编程学习(1)前言

信号量,管程

并发编程的三个核心问题:分工,同步,互斥

1.分工

如何进行分工、分配问题;

Java SDK 并发包里的 Executor、Fork/Join、Future 本质上都是一种分工方法。除此之外,并发编程领域还总结了一些设计模式,基本上都是和分工方法相关的,例如生产者 - 消费者、Thread-Per-Message、Worker Thread 模式等都是用来指导你如何分工的。

2.同步

任务之间是有依赖的,一个任务结束后,依赖它的后续任务就可以开工了,后续工作怎么知道可以开工了呢?这个就是靠沟通协作了,这是一项很重要的工作。

在 Java 并发编程领域,解决协作问题的核心技术是管程,上面提到的所有线程协作技术底层都是利用管程解决的。管程是一种解决并发问题的通用模型,除了能解决线程协作问题,还能解决下面我们将要介绍的互斥问题。可以这么说,管程是解决并发问题的万能钥匙

理解管程模型

3.互斥

所谓互斥,指的是同一时刻,只允许一个线程访问共享变量。

实现互斥的核心技术就是锁,Java 语言里 synchronized、SDK 里的各种 Lock 都能解决互斥问题

可以分场景优化,Java SDK 里提供的 ReadWriteLock、StampedLock 就可以优化读多写少场景下锁的性能。

并发编程理论知识1|可见性、原子性和有序性问题1.1 CPU、内存、I/O

CPU,内存,I/O之间有着巨大的读写速度差别。此时,I/O设备等会限制计算机的性能;为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

CPU 增加了缓存,以均衡与内存的速度差异;操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。1.2 缓存导致的可见性问题

对于单核CPU来讲,所有的线程都是再一个CPU上进行运行的,CPU的缓存与内存的一致性很容易解决。一个线程对CPU缓存的写,对另外一个线程来说一定是可见的。

一个线程对共享变量的修改,另外一个线程可以立马看到,我们称之为可见性。

在多核服务器或者计算机上,每颗CPU都有着自己的缓存,此时CPU缓存和内存的数据一致性没有那么容易解决。此时线程A对线程B的操作不具备可见性。

public class Test { private long count = 0; private void add10K() { int idx = 0; while(idx++ { test.add10K(); }); Thread th2 = new Thread(()->{ test.add10K(); }); // 启动两个线程 th1.start(); th2.start(); // 等待两个线程执行结束 th1.join(); th2.join(); return count; } }

运行结果是10000,20000之间的一个随机数; 刚开始,两个CPU中缓存中count都是0,此时写入内存中,count+1=1,此时如果有一个线程快,一个线程慢,则两个线程会读取对方写入内存的值,导致两个线程可能会快,也可能会慢。期望在10000左右。

1.3 线程切换带来的原子性问题

时间片,进程和线程切换; Java 并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异 Bug 的源头之一。

我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性

1.4 编译优化带来的有序问题

Java中经典案例利用双重检查创建单例对象

public class Singleton { static Singleton instance; static Singleton getInstance(){ if (instance == null) { synchronized(Singleton.class) { if (instance == null) instance = new Singleton(); } } return instance; } }

这里的getInstance()方法,首先判断当前是否有该类的实例对象,如果没有返回一个对象; 假设,此时有两个线程A,B同时调用getInstance()函数,有可能同时发现instance = null 于是采用了加锁的方式,锁定该对象;

问题在哪?

分配一块内存 M;在内存 M 上初始化 Singleton 对象;然后 M 的地址赋值给 instance 变量。

但是实际上优化后的执行路径却是这样的:

分配一块内存 M;将 M 的地址赋值给 instance 变量;最后在内存 M 上初始化 Singleton 对象。

此时,有可能返回空的指针;

Q:在32位机器上,执行long类型计算,可能发生并发问题?

long类型64位,所以在32位的机器上,对long类型的数据操作通常需要多条指令组合出来,无法保证原子性,所以并发的时候会出问题

2|Java内存模型:Java如何解决可见性和有序性问题2.1 什么是Java内存模型

在上一节中,缓存和编译优化导致了可见性和有序性问题;最直接的办法就是禁用缓存和编译优化

合理方案应当是按需禁用缓存以及编译优化

Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。方法包括:volatile、synchronized、final三个关键字以及六项Happens-Before规则

2.2 使用volatile关键字

volatile关键字并不是Java语言的特产,他的原始意义为禁用CPU缓存。

例如,我们声明一个volatile变量volatile int x = 0,该变量不能从CPU缓存中读写,只能够从内存中进行读写;

class VolatileExample { int x = 0; volatile boolean v = false; public void writer() { x = 42; v = true; } public void reader() { if (v == true) { // 这里 x 会是多少呢? } } }

这里的writer,reader,都只能够从内存进行写入或者读取boolean变量。在Java 1.5版本以前会读出x = 0的情况。这是因为x的缓存问题,虽然x的内存为42但是缓存为0。在1.5版本后,对volatile关键字进行了语义增强。

2.3 Happens-Before规则

前面一个操作的结果对后续操作是可见的。

比较正式的说法:Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守Happens-Before规则。

2.3.1 程序的顺序性规则

程序前面对某个变量的修改一定是对后续操作可见的。

2.3.2 volatile 变量规则

对一个volatile变量的写操作,Happens-Before于后续对这个volatile变量的读操作。

2.3.3 传递性

Happens-Before具有传递性;

此时写x对于读x就是可见的。

2.3.4 管程中的锁规则

管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现

2.3.5 线程start()规则

主线程A启动子线程B后,子线程能够看到主线程在启动子线程B前的操作。

2.3.6 线程join()规则

指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。

final

逸出:Java中将一个类的对象错误的发布到全局变量中,这样其就会被储存在静态区,内存无法回收,导致内存泄露;

Q:有一个共享变量 abc,在一个线程里设置了 abc 的值 abc=3,你思考一下,有哪些办法可以让其他线程能够看到abc==3?

abc无缓存,使用volatile关键字对abc赋值加锁,管程锁规则对其他线程启用start()定义为final关键字3.互斥锁|解决程序原子性问题

原子性的问题根源来自于线程切换,如果能够禁用线程切换就可以解决该问题;

在早期单核 CPU 时代,这个方案的确是可行的,而且也有很多应用案例,但是对于多核场景。同样会出现原子性问题,此时相当于你和别人一起写;单核原子性问题相当于,你写了一半被人改了;

所以核心在于同一时刻只有一个线程执行,这被我们称之为互斥

3.1简易锁模型

锁与锁的保护资源具有对应关系(一个资源一个锁)

3.2 Java语言提供的锁技术:synchronized

synchronized关键字可以用来修饰方法,也可以用来修饰代码块;

class X { // 修饰非静态方法 synchronized void foo() { // 临界区 } // 修饰静态方法 synchronized static void bar() { // 临界区 } // 修饰代码块 Object obj = new Object(); void baz() { synchronized(obj) { // 临界区 } } }

Java隐式规则: 当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X; 当修饰非静态方法的时候,锁定的是当前实例对象 this

原子性问题和可见性问题同样重要:

class SafeCalc { long value = 0L; long get() { return value; } synchronized void addOne() { value += 1; } }

此时addOne()操作具有原子性,且运行该代码的线程之间是可见的,但get函数没有加锁,其对于这些不具有可见性;

class SafeCalc { long value = 0L; synchronized long get() { return value; } synchronized void addOne() { value += 1; } }

加一样的锁,不具有原子性和可见性问题

class SafeCalc { static long value = 0L; synchronized long get() { return value; } synchronized static void addOne() { value += 1; } }

一个给这个对象的所有类进行加锁,另外一个给当前对象进行加锁不具有可见性;

4.互斥锁|一把锁保护多个资源

我们知道,首保护资源和锁之间的合理关联关系是N:1的关系,可以用一把锁加给多个资源,但是不能够用多个锁来保护一个资源。如何实现呢?

4.1 保护没有关联关系的多个资源 class Account { // 锁:保护账户余额 private final Object balLock = new Object(); // 账户余额 private Integer balance; // 锁:保护账户密码 private final Object pwLock = new Object(); // 账户密码 private String password; // 取款 void withdraw(Integer amt) { synchronized(balLock) { if (this.balance > amt){ this.balance -= amt; } } } // 查看余额 Integer getBalance() { synchronized(balLock) { return balance; } } // 更改密码 void updatePassword(String pw){ synchronized(pwLock) { this.password = pw; } } // 查看密码 String getPassword() { synchronized(pwLock) { return password; } } }

这里使用语句创建锁:

private final Object balLock = new Object();

采用final关键字,创建一个锁;

4.2 保护有着关联关系的多个资源

例如转账场景中:

A,B,C余额都是200,此时我们希望用两个线程,A->B,100,B->C,100.最终结果应该是,A:100,B:200,C:300;

此时线程1和线程2是互斥的嘛?并不是运行在两个CPU上,此时锁不能覆盖所有的保护资源。

使用锁覆盖所有的保护资源

class Account { private Object lock; private int balance; private Account(); // 创建 Account 时传入同一个 lock 对象 public Account(Object lock) { this.lock = lock; } // 转账 void transfer(Account target, int amt){ // 此处检查所有对象共享的锁 synchronized(lock) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } }

解决方案,在构造函数中,传入一个相同的lock锁对象,此时锁住lock。给所有的资源加一把锁;!但是缺乏可操作性;

可以直接传入Account.class

课后思考题:

不可以用可变对象作为锁

5.死锁问题

在上一篇中,使用Account.class作为互斥锁解决银行转账问题,但是我们会发现这个方案都是串行的,当一个人进行转账时,其他人都不能进行操作,阻塞状态;性能过差;

如何解决这个问题?

其实就是减小粒度,只锁转出和转入两个账户的临界区

class Account { private int balance; // 转账 void transfer(Account target, int amt){ // 锁定转出账户 synchronized(this) { // 锁定转入账户 synchronized(target) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } } }

需要等转入账户传入; 这样的方法叫做细粒度锁,使用细粒度锁可以提高并行度,是性能优化的重要手段;

使用细粒度锁是有代价的,这个代价就是可能会导致死锁。

死锁! 线程1 :此时A给B转账,需要B的对象,被线程2锁

线程2:此时B也同时给A转账,需要A对象,被线程1锁

死锁发生的条件:

互斥,共享资源X,Y只能被一个线程占用,占有后其他线程不能占有。占有且等待,线程T1已经X资源,等待Y资源时,不释放共享资源。不可抢占,其他线程不能抢占T1占有的资源循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

只要破坏一个就可以避免死锁发生:

其中互斥无法破坏:

一次申请所有资源,如果不能申请所有资源不申请占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了5.1 破坏占用且等待条件

即不能够在占有资源时等待其他资源。破坏该条件可以通过一次性申请所有资源。这里我们就需要一个管理器,对所有的账本进行管理。该角色叫做Allocator,有着两个重要的功能同时申请资源apply()和同时释放资源free() 实现代码如下:

class Allocator { private List als = new ArrayList(); // 一次性申请所有资源 synchronized boolean apply( Object from, Object to){ if(als.contains(from) || als.contains(to)){ return false; } else { als.add(from); als.add(to); } return true; } // 归还资源 synchronized void free( Object from, Object to){ als.remove(from); als.remove(to); } } class Account { // actr 应该为单例 private static Allocator actr; private int balance; // 转账 void transfer(Account target, int amt){ // 一次性申请转出账户和转入账户,直到成功 while(!actr.apply(this, target)) ; try{ // 锁定转出账户 synchronized(this){ // 锁定转入账户 synchronized(target){ if (this.balance > amt){ this.balance -= amt; target.balance += amt; } } } } finally { actr.free(this, target) } } }

可以这么理解,这里的资源管理器Allocator。是一个单例模式,一个线程占用他进行apply和free操作的时候其他线程是无法进行占用的。例如此时有一个线程T1。

首先对Account进行实例化,然后调用actr.apply。此时锁定了父类中的单例actr。此时其他线程无法获得actr对象的锁,此时线程1发现有人在用该对象,即有某个线程把该对象已经写入了actr中,此时另外一个线程free了该对象,得到对象的锁开始执行。

看看我要用的资源有没有人正在用的其实就是。

5.2 破坏不可抢占

破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。

5.3 破坏循环等待

对资源进行排序

class Account { private int id; private int balance; // 转账 void transfer(Account target, int amt){ Account left = this ① Account right = target; ② if (this.id > target.id) { ③ left = target; ④ right = this; ⑤ } ⑥ // 锁定序号小的账户 synchronized(left){ // 锁定序号大的账户 synchronized(right){ if (this.balance > amt){ this.balance -= amt; target.balance += amt; } } } } }

先锁大后锁小

6.使用等待通知优化循环等待

回顾在上一节中我们所使用的apply,如果并发很高此时循环成本太大。所以不太可行;

,最好的方案应该是:如果线程要求的条件(转出账本和转入账本同在文件架上)不满足,则线程阻塞自己,进入等待状态;当线程要求的条件(转出账本和转入账本同在文件架上)满足后,通知等待的线程重新执行。

等待-通知机制

一个完整的等待 - 通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁

如何实现等待通知机制?

使用wait方法后,线程进入阻塞队列且释放所持有的互斥锁。 当条件满足时调用 notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过

这些方法都是互斥锁的等待队列:wait()、notify()、notifyAll() 都是在 synchronized{}内部被调用的。

class Allocator{ private static List als; ​ synchronized void apply(Object from,Object to) { while(als.contains(from)||als.contains(to)) { try { wait(); } catch (InterruptedException e) { throw new RuntimeException(e); } } als.add(from); als.add(to); } ​ synchronized void free( Object from, Object to) { als.remove(from); als.remove(to); notifyAll(); } }

wait()方法与sleep()方法的不同之处在于,wait()方法会释放对象的“锁标志”。

7.安全、活跃性和性能

并发编程中我们需要注意的问题有很多,很庆幸前人已经帮我们总结过了,主要有三个方面,分别是:安全性问题、活跃性问题和性能问题。下面我就来一一介绍这些问题。

7.1 安全性问题

什么是线程安全?本质上就是正确性,使得程序按照我们期望的执行。理论上的线程安全,就是要解决原子性、可见性、有序性问题。

多线程同时读写同一数据时,就会出现线程不安全。

数据竞争:两个线程修改同一共享数据。

竞态条件:程序的执行结果依赖于执行的顺序。

7.2 活跃性问题

公平锁:按照先来后到排队,解决线程优先级带来的不便。

void addIfNotExist(Vector v, Object o){ if(!v.contains(o)) { v.add(o); } }

这里就存在竞态条件问题;

8.管程

Java 采用的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。而管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。但是管程更容易使用,所以 Java 选择了管程。

管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。哈哈哈,管理共享变量以及对共享变量操作的过程。好好好。

8.1 MESA模型实现管程

一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。

将所有的共享变量和对共享变量的操作统一封装起来;只允许一个线程进入管程。

管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,如下图,条件变量 A 和条件变量 B 分别都有自己的等待队列。

条件变量的等待队列解决了同步问题。线程1进入,发现条件变量不满足,进入条件变量A,线程2进入改变了条件变量A此时,通知线程1,线程1又去外面排队。

为什么wait一定要在while循环中使用?(MESA管程特有?)可能在线程排队期间条件又不满足了,所以如果用if的话,下一次进来就跳过了这一步判断。

为什么wait()在MESA模型中增加了超时参数?避免一直傻等。 如果没超时,A线程wait了,由于代码的bug,没有其他线程notify,就会导致A一直wait。增加超时之后,A线程可以自己来决定是否继续等待。这样代码的健壮性会更好

9.Java线程状态

RUNNABLE 与BLOCKED状态转换,只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁。JVM 层面并不关心操作系统调度相关的状态,因为在 JVM 看来,等待 CPU 使用权(操作系统层面此时处于可执行状态)与等待 I/O(操作系统层面此时处于休眠状态)没有区别,都是在等待某个资源,所以都归入了 RUNNABLE 状态。

RUNNABLE与WAITING状态转换,调用了wait方法,调用了join方法,A等待B执行完。LockSupport.park()方法

选择多少线程?

最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3