当前位置:主页 > 查看内容

并发编程-初级之认识并发编程

发布时间:2021-07-13 00:00| 位朋友查看

简介:并发编程-初级之认识并发编程 1.并发领域可以处理的问题 分工 同步 分好工之后就可以具体执行。任务之间是有依赖的一个任务结束之后将去去通知后续的任务。java里面 Executor、Fork/Join、Future 本质上都 是分工方法但同时也能解决线程协作的问题。 例如用……

并发编程-初级之认识并发编程

1.并发领域可以处理的问题

  1. 分工
  2. 同步:分好工之后,就可以具体执行。任务之间是有依赖的,一个任务结束之后将去去通知后续的任务。java里面 Executor、Fork/Join、Future本质上都
    是分工方法,但同时也能解决线程协作的问题。

例如,用 Future 可以发起一个异步调用,主线程通过 get() 方法取结果时,主线程就会等待,当异步执行的结果返回时,get() 方法就自动返回了。
我之前用的是CountDownLatch,先创建多个线程实现分工,再用CountDownLatch来一起协

  1. 互斥:这里要说的就是线程安全的问题。导致这种多个线程访问一个变量的时候的不确定性的源头是由于可见性问题,有序性问题,原子性问题
    而当我们讲到互斥其实我们是在说***同一时刻,只允许一个线程访问共享 变量***。
    而实现互斥的核心就是锁。
    在这里插入图片描述

2.可见性,原子性和有序性问题:并发编程bug源头

1.什么是可见性

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

1.1 缓存问题导致的可见性问题
1.2 线程切换带来的原子性问题
在这里插入图片描述
1.3 编译优化带来的有序性问题

3.Java内存模型:java如何解决可见性和有序性问题

解决这种问题合理方案按需禁用缓存,编译优化

1.按需禁用缓存

1.1 volatile关键字

volatile 关键字并不是 Java 语言的特产,古老的 C 语言里也有,它最原始的意义就是禁用
CPU 缓存。

在java 1.5后 对volite使用Happens-Before增强。

Happens-Before规则(java-可见性)

大意:前一个操作的结果对后续的操作是可见的。
Happens-Before 约束了
编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 HappensBefore 规则

一共有六项

1.程序的顺序性规则

1 // 以下代码来源于【参考 1】 2 class VolatileExample {
3 int x = 0;
4 volatile boolean v = false;
5 public void writer() {
6 x = 42;
7 v = true;
8 }
9 public void reader() {
10 if (v == true) {
11 // 这里 x 会是多少呢?
    42// 1.5之前这里x=0
12 }
13 }
14 }

2.volatile规则

3.传递性:这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A HappensBefore C。

在这里插入图片描述

从图中,我们可以看到:

1. “x=42” Happens-Before 写变量 “v=true” ,这是规则 1 的内容;

2. 写变量“v=true” Happens-Before 读变量 “v=true”,这是规则 2 的内容 。
再根据这个传递性规则,我们得到结果:“x=42” Happens-Before 读变
量“v=true”。这意味着什么呢?
如果线程 B 读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 是可见的。也就是
说,线程 B 能看到 “x == 42” ,有没有一种恍然大悟的感觉?这就是 1.5 版本对
volatile 语义的增强,这个增强意义重大,1.5 版本的并发工具包(java.util.concurrent)

ps:这个规则也解释了为什么之前我们看到的x是等于42而不是等于0
4.管程中锁的规则

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

1 synchronized (this) { // 此处自动加锁
2 // x 是共享变量, 初始值 =10
3 if (this.x < 12) {
4 this.x = 12; 
5 } 
6 } // 此处自动解锁

所以结合规则 4——管程中锁的规则,可以这样理解:假设 x 的初始值是 10,线程 A 执行
完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程
A 对 x 的写操作,也就是线程 B 能够看到 x==12。这个也是符合我们直觉的,应该不难理
解。

5.线程start()规则

这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启
动子线程 B 前的操作。

1 Thread B = new Thread(()->{
2 // 主线程调用 B.start() 之前
3 // 所有对共享变量的修改,此处皆可见
4 // 此例中,var==77
5 });
6 // 此处对共享变量 var 修改
7 var = 77;
8 // 主线程启动子线程
9 B.start();

6.线程join原则

这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B
的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看
到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。
换句话说就是,如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意
操作 Happens-Before 于该 join() 操作的返回。具体可参考下面示例代码。

1 Thread B = new Thread(()->{
2 // 此处对共享变量 var 修改
3 var = 66;
4 });
5 // 例如此处对共享变量修改,
6 // 则这个修改结果对线程 B 可见
7 // 主线程启动子线程
8 B.start();
9 B.join()
10 // 子线程所有对共享变量的修改
11 // 在主线程调用 B.join() 之后皆可见
12 // 此例中,var==66

所以以前我们的单例模式,其实会导致空指针异常 因为New对象是三步 - 1.开内存 2.堆上初始化一个对象 3.在吧地址给那个栈 但是java会进行优化 2,3步会交换 所以 很多线程访问的时候 可能会拿到instance!= null 然后他们就拿着这个对象走了 但其实这个对象是没有内存的 会出现空指针异常

public class DoubleCheckLock {
    private static Instance instance;
    public static Instance getInstance(){
        // 第一次检查
        if(instance==null){
            // 第一次检查为null再进行加锁,降低同步带来的性能开销
            synchronized (DoubleCheckLock.class){
                // 第二次检查
                if(instance==null){
                    // 问题出在此处
                    instance=new Instance();
                }
            }
        }
        return instance;
    }
}

所以解决方案就是 Instance 加上volatile关键字 防止java的优化

final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。


4.互斥锁

解决原子性问题

本质:互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定减少持有锁的时间

最原始粗暴的锁模型
最原始最粗暴的锁

在上面的锁在我们看来是非常简单粗暴的。在现实世界里,锁和要保护的资源是有对应关系的,比如我家的锁锁我家的门。
所以引进了下面一种模型
在这里插入图片描述
首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的
资源 R;其次,我们要保护资源 R 就得为它创建一把锁 LR;最后,针对这把锁 LR,我们
还需在进出临界区时添上加锁操作和解锁操作。另外,在锁 LR 和受保护资源之间,我特地
用一条线做了关联,这个关联关系非常重要。很多并发 Bug 的出现都是因为把它忽略了,
然后就出现了类似锁自家门来保护他家资产的事情,这样的 Bug 非常不好诊断,因为潜意
识里我们认为已经正确加锁了。

图画出来了,但是这个图在java里面是怎样实现呢?
我们接着往下面看。

1.synchronized :可以用来修饰方法和代码块

1  class X {
2 // 修饰非静态方法
3 synchronized void foo() {
4 // 临界区
5 }
6 // 修饰静态方法
7 synchronized static void bar() {
8 // 临界区
9 }
10 // 修饰代码块
11 Object obj = new Object()12 void baz() {
13 synchronized(obj) {
14 // 临界区
15 }
16 }
17 }

用synchronized可以自动lock()和unLock(),而在修饰代码块的时候,锁定了一个Obj,那他修饰方法呢?

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

锁和受保护资源的关系

资源->锁 多->一

ps:当多个锁锁住同一个资源(方法或者是什么)那么就会线程不安全

如何用一把锁保护多个资源

1.保护没有关联关系的多个资源 -上不同的锁
2.保护有关联关系的多个资源

评估性能的指标:
1.吞吐量
2.延迟
3.并发量:

评估性能的指标

吞吐量延迟并发量
单位时间内能处理的请求数量发出请求到收到响应的时间同时处理的请求数量

5.管程

1.什么是管程
管程,对应的英文是 Monitor,很多 Java 领域的同学都喜欢将其翻译成“监视器”,这是
直译。操作系统领域一般都翻译成“管程”,这个是意译,而我自己也更倾向于使用“管
程”。
是Java并发使用的技术,而其体现在synchronized 关
键字及 wait()、notify()、notifyAll()

2.作用: 指的是管理共享变量以及对共享变量的操作过程,让他们支持并发

3.广泛使用的管程模型:MESA

3.1 互斥:
管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。在
下图中,管程 X 将共享变量 queue 这个队列和相关的操作入队 enq()、出队 deq() 都封装
起来了;线程 A 和线程 B 如果想访问共享变量 queue,只能通过调用管程提供的 enq()、
deq() 方法来实现;enq()、deq() 保证互斥性,只允许一个线程进入管程。
在管程模型里,对于共享变量的操作是被封装的。图中最外层的框就代表封装。框的上面只有一个入口,并且在入口旁边有一个等待队列,当多个线程同时进入时。只允许一个线程进入。其他则在队伍中等待。

在这里插入图片描述

在这里插入图片描述

条件变量
每个条件变量对应一个条件变量等待队列。比如说有一个条件变量 A,当执行线程 T1 时发现不满足条件变量 A,T1 就会进入条件变量 A 的等待队列中。就像去看医生,医生让你先去排个 X 光,就要去拍 X 光的地方排队。

public class BlockedQueue<T>{
  final Lock lock =
    new ReentrantLock();
  // 条件变量:队列不满  
  final Condition notFull =
    lock.newCondition();
  // 条件变量:队列不空  
  final Condition notEmpty =
    lock.newCondition();

  // 入队
  void enq(T x) {
    lock.lock();
    try {
      while (队列已满){
        // 等待队列不满 
        notFull.await();
      }  
      // 省略入队操作...
      //入队后,通知可出队
      notEmpty.signal();
    }finally {
      lock.unlock();
    }
  }
  // 出队
  void deq(){
    lock.lock();
    try {
      while (队列已空){
        // 等待队列不空
        notEmpty.await();
      }
      // 省略出队操作...
      //出队后,通知可入队
      notFull.signal();
    }finally {
      lock.unlock();
    }  
  }
}

3.2 synchronized 单条件变量的管程模型
Java内置的管程方案(synchronized)只支持一个条件变量
而如果要支持多个 可以引用Java 的Sdk


6.线程的生命周期

  1. NEW(初始化状态)
  2. RUNNABLE(可运行 / 运行状态)
  3. BLOCKED(阻塞状态)
  4. WAITING(无时限等待)
  5. TIMED_WAITING(有时限等待)
  6. TERMINATED(终止状态)

只要线程处于BLOCKED、WAITING、TIMED_WAITING那么这个线程就永远没有 CPU 的使用权,这个时候被如果被Interrute()就会报异常

如何从New切换到 WAITING
调用start()

如何从Runnable切换到 WAITING

  1. Object.wait()
  2. Thread.join()
  3. LockSupport.park()

如何从Runnable切换到 TIMED_WAITING

1.调用带超时参数的 Thread.sleep(long millis) 方法;
2.带超时参数的 Object.wait(long timeout)
3.带超时参数的 Thread.join(long millis

如何从Runnable切换到 WAITING
1.程序执行完
2.stop() 超级不建议使用
3.interrupt() 正确使用

TIMED_WAITING 和 WAITING 状态的区别,仅仅是触发条件多了超时参数

7.使用线程的正确姿势 降低延迟,提高吞吐量

7.1 创建最优数量线程

单核CPU
I/O 密集型计算 1 +(I/O 耗时 / CPU 耗时)

CPU 密集型计算 CPU 核数 +1
多核CPU
CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]

8.局部变量的安全性

局部变量是安全的,因为他只存在于栈中,且每个方法都有自己的调用栈。

线程封闭:方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经成为解决并发问题的一个重要技术,同时还有个响当当的名字叫做线程封闭,比较官方的解释是:仅在单线程内访问数据。

比如数据库连接Conn,因为JDBC规范并没有要求必须是线程安全的,数据库连接池通过线程封闭技术,保证一个Conn一旦被一个线程获取后,在这个线程关闭之前不会把这个Con分给其他线程

9.Java面向对象思想与并发编程的融合之旅

9.1 封装共享变量

对于这些不会发生变化的共享变量,建议你用 final 关键字来

9.2 注意If判断

在我们的指定并发访问策略是利用原子类。所以我们要特别注竞态条件判断
反应在代码里面


public class SafeWM {
 // 库存上限
 private final AtomicLong upper =
 new AtomicLong(0);
 // 库存下限
 private final AtomicLong lower =
 new AtomicLong(0);
 // 设置库存上限
 void setUpper(long v){
 // 检查参数合法性
 if (v < lower.get()) {
 throw new IllegalArgumentException();
 }
 upper.set(v);
 }
 // 设置库存下限
 void setLower(long v){
 // 检查参数合法性
 if (v > upper.get()) {
 throw new IllegalArgumentException();
 }
 lower.set(v);
 }
 // 省略其他业务代码
}


在这个类中,我们用了原子类AtomicLong来保证我们存取的值的安全性。
我们假设库存的下限和上限分别是 (2,10),线程 A 调用 setUpper(5) 将上限设置为 5,线
程 B 调用 setLower(7) 将下限设置为 7,如果线程 A 和线程 B 完全同时执行,你会发现线
程 A 能够通过参数校验,因为这个时候,下限还没有被线程 B 设置,还是 2,而 5>2;线
程 B 也能够通过参数校验,因为这个时候,上限还没有被线程 A 设置,还是 10,而
7<10。当线程 A 和线程 B 都通过参数校验后,就把库存的下限和上限设置成 (7, 5) 了,显
然此时的结果是不符合库存下限要小于库存上限这个约束条件的。
这个时候光只用原子类是不行的。

9.4 制定并发访问策略

三个方案

1.避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
我的理解可能就是ThreadLocal

2.不变模式:java运用少 不解释

3.管程及其他同步工具:java并发包

三原则

1.优先使用成熟的工具类,而不是自己造轮子

2.迫不得已才使用低级的同步原理
这里主要指的是 synchronized、Lock、
Semaphore 等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。

3.避免过早优化:并发程序首先要保证安全,出现性能瓶颈时再优化


总结

思考题:

1.下面的代码用 synchronized 修饰代码块来尝试解决并发问题,你觉得这个使用方式正确
吗?有哪些问题呢?能解决可见性和原子性问题吗?

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

我的答案:不正确。图中的资源是Value,但是在两个方法中用了不同的锁去请求方法,所以会出现线程不安全。

官方答案:,每次调用方法 get()、addOne() 都创建了不同的锁,相当于无锁。这里需要
你再次加深一下记忆,“一个合理的受保护资源与锁之间的关联关系应该是 N:1”。只有
共享一把锁才能起到互斥的作用。

2.加粗:这个问题我错了

class Account {
 // 账户余额 
 private Integer balance;
 // 账户密码
 private String password;
 // 取款
 void withdraw(Integer amt) {
 synchronized(balance) {
 if (this.balance > amt){
 this.balance -= amt;
 }
 }
 } 
 // 更改密码
 void updatePassword(String pw){
 synchronized(password) {
 this.password = pw;
 }
 } 
}

我的答案:这个对象多个线程用的如果是同一个,那么就可行。

官方答案错!!!!
1.锁可能会变
2.Interger和Sting类型不适合做锁,因为他们在JVM可能被重用。
那么你的锁可能别其他代码使用,如果其他代码 synchronized(你的锁),而且不释放,那你的程序就永远拿不
到锁,这是隐藏的风险

通过这两个反例,我们可以总结出这样一个基本的原则:锁,应是私有的、不可变的、不可
重用的。我们经常看到别人家的锁,都长成下面示例代码这样

// 普通对象锁
private final Object 
 lock = new Object();
// 静态对象锁
private static final Object
 lock = new Object();

撒花~~~~

;原文链接:https://blog.csdn.net/weixin_44102324/article/details/115703452
本站部分内容转载于网络,版权归原作者所有,转载之目的在于传播更多优秀技术内容,如有侵权请联系QQ/微信:153890879删除,谢谢!
上一篇:RedisAtomicLong自增并发出现重复编号 下一篇:没有了

推荐图文


随机推荐