并发编程——共享对象的正确使用

并发编程——共享对象的正确使用,第1张

并发编程——共享对象的正确使用 共享对象的正确使用 概述

​ 在JMM,Java内存模型中讲过,其实并发编程的问题主要在多线程对共享变量的修改读取上,那么互斥(加锁),其实是通过同步来避免多个线程在同一时刻访问共享变量,但是其实并不是所有的场景都需要加锁。比如并不是所有的场景都会更改共享变量的值,它们仅仅需要把主内存的数据读取出来,然后在工作内存中拷贝一份来读取(线程本地存储);也不是所有的场景都要求立马读取最新的数据(cop-on-write);甚至有些场景直接不共享变量的值—线程封闭;又或者这个变量的值永远都不会改变—不变模式。

​ 本文首先会介绍如何正确的封装与发布共享变量,然后会按照不同的需求推荐并发访问策略。

​ 锁会在后面重点描述,所以本文略过


封装共享变量

​ 如果从面向对象的思想来考虑并发编程的安全性,其实就是将共享变量作为对象属性封装在内部,然后对所有公共的方法指定并发访问策略。但是这就涉及到两个问题——在并发编程下,保证对象的可见性与正确的发布对象,防止对象逸出。


对象的可见性

​ 这里指的可见性与JMM中提到的可见性其实是一个意思。在多个线程同时访问一个共享变量的情况下,可见性的问题尤为重要:

​ 试想一下,i++的例子中,线程1把i由0改成了1,线程2同时也把i由0改成了1,如果变量i没有任何同步措施保证,那么线程1跟2同时把工作内存中的i回写到主内存,那么主内存中的i最终值就是1,因为线程1跟线程2中各自工作内存中变量i的值互相不可见。

​ 保证对象可见性有很多种方案,根据JMM的Happends-Before原则,可以通过加锁与volatile两种方式。

加锁

​ 加锁并不仅仅局限于互斥行为,它也包括内存可见性。

​ 当线程A执行到某个同步代码块,线程B随后进入到同一个锁保护的同步代码块,那么根据Happens-Before原则,线程A释放锁后,线程B获得了锁,那么线程A对同步代码块中所有的 *** 作结果对线程B都是可见的。

volatile

​ volatile是Java提供的一种稍弱的同步机制。它主要可以做两件事:确保将变量的更新 *** 作通知到其他线程;不会将该变量上的 *** 作与其他内存 *** 作一起重排序。简单的说,就是可见性与防止指令重排。volatile变量不会被缓存到寄存器或者其他对处理器不可见的地方,因此读取volatile类型的变量时总会返回最新的值。

volatile原理

“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”——《深入理解Java虚拟机》

​ lock前缀指令相当于一个内存屏障,主要做了这几件事:

  • ​ lock *** 作相当于一个写屏障,指令重排的时候不能把后面的指令重排到内存屏障之前的位置

  • ​ lock前缀使得本CPU的cache写入了缓存,相当于会立刻把工作内存中这个变量的值回写到主内存

  • ​ 该写入动作也会引起别的CPU或者别的内核的缓存无效,强制别的线程的工作内存中,这个变量的缓存无效,要求别的线程立刻从主内存读取这个变量的值

    通过这个 *** 作也就可以保证volatile变量的修改对其他CPU立即可见

volatile的正确使用

​ 加锁即可以保证可见性,又可以保证原子性,但是volatile变量仅仅可以保证可见性。

​ 只有在这几种条件下,才应该使用volatile变量:

  • ​ 对变量的写入 *** 作不依赖变量的当前值,或者确保只有单个线程更新变量的值(比如volatile修饰变量i,但是对变量i做++ *** 作并不是线程安全的)
  • ​ 该变量不会与其他状态状态变量一起纳入不变条件
  • ​ 在访问该变量时不需要加锁

对象的发布与逸出

发布:使对象能够在当前作用域之外的代码中使用

逸出:当某个不应该发布的对象被发布时,就称为逸出

逸出的几种情况:
  • ​ 方法返回一个private对象

  • ​ 还未完成初始化就把对象提供给外部:

    • 在构造函数中未初始化完毕就this赋值

    • 隐式逸出——注册监听事件

    • 构造函数中运行线程

逸出举例 举栗一:

​ 方法返回一个private对象

public class Example1 {
    public static void main(String[] args) {
        UnSafeArr unSafeArr=new UnSafeArr();
        String[] arr = unSafeArr.getArr();
        arr[0]="77";
        System.out.println(Arrays.toString(unSafeArr.getArr()));
    }
}
class UnSafeArr{
    private String[] arr = {"11", "22", "33"};

    public String[] getArr() {
        return arr;
    }
}

结果:

​ 可以看到,按照上面的方式发布arr,任何调用者都可以对这个数组进行修改。在这个栗子中,arr数组已经逸出了它所在的作用域了,这显然不是我们期望的。

举栗二:
public class MultiThreadsError4 {

    static Point point;

    public static void main(String[] args) throws InterruptedException {
        new PointMaker().start();
        	Thread.sleep(10);
//        Thread.sleep(105);
        if (point != null) {
            System.out.println(point);
        }
    }
}

class Point {

    private final int x, y;

    public Point(int x, int y) throws InterruptedException {
        this.x = x;
        MultiThreadsError4.point = this;
        Thread.sleep(100);
        this.y = y;
    }

    @Override
    public String toString() {
        return x + "," + y;
    }
}

class PointMaker extends Thread {

    @Override
    public void run() {
        try {
            new Point(1, 1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

​ 先执行Thread.sleep(10);会看到打印的结果是x=1,y=0,然后换成Thread.sleep(105);会看到x=1,y=1

​ 显然,我们期望看到两次访问的值是相等的。

举栗三:
public class MultiThreadsError6 {

    private Map states;

    public MultiThreadsError6() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                states = new HashMap<>();
                states.put("1", "周一");
                states.put("2", "周二");
                states.put("3", "周三");
                states.put("4", "周四");
            }
        }).start();
    }

    public Map getStates() {
        return states;
    }

    public static void main(String[] args) throws InterruptedException {
        MultiThreadsError6 multiThreadsError6 = new MultiThreadsError6();
        //Thread.sleep(1000);
        System.out.println(multiThreadsError6.getStates().get("1"));
    }
}

运行结果

​ 所以不要在构造函数中起线程去初始化~


共享变量的访问策略 线程封闭

​ 当访问共享的可变数据时,通常需要使用同步,一种避免使用同步的方式就是不需要共享数据。如果仅在单线程内部访问数据,那就不需要同步。这种技术被称为线程封闭,它是实现线程安全性最简单的方式之一。

​ 例如在Swing中,大量使用了线程封闭技术,JDBC的Connection对象也使用了线程封闭技术:在典型的服务器应用程序中,线程从连接池中获取一个Connection对象,并用它来处理请求,使用完之后再把对象还给连接池。由于大多数请求都是由单个线程采取同步的方式来处理的,并且在Connection对象返回之前,连接池不会再把它分配给其他的线程,因此这种链接管理模式在处理请求时隐含的将Connection对象封闭在线程之中。

栈封闭

​ 在栈封闭中,只有通过局部变量才能访问对象。

线程本地存储

​ 线程本地存储,ThreadLocalStorage。从字面上理解,就是这些变量在每个线程中都存在一份副本,每个线程之间互不干扰,互不影响。适用于全局变量进行共享,比如:将JDBC的链接保存到ThreadLocal对象中,每个对象都会有自己的链接;上下文等场景。

不变模式

​ 如果某个对象在创建之后,它的状态就不能被修改,那么这个对象就被称为不可变对象。不可变对象一定是线程安全的。

​ 满足以下条件的对象才是不可变的:

​ 对象创建后其状态就不能被修改

​ 对象的所有域都是final类型

​ 对象是正确创建的(对象创建期间,this引用没有逸出)

CAS

​ CAS,乐观锁,它虽然被叫做锁,但是它的实现过程中并没有真正的去加锁。它会记录下首次访问时对象的值,然后对它 *** 作后去比较对象现在的值,如果现在的值=首次访问的值,那么就认为线程在 *** 作期间,没有别的线程对它进行修改。当然,CAS的思想会有ABA问题,解决ABA问题的方法之一就是版本号,共享变量都维护一个版本号,每次 *** 作就+1,这样就可以有效的防止ABA问题。(这也是Mysql的Innodb存储引擎的并发控制方案MVCC的设计思想)

CopyonWrite

​ 这种思想的核心是如果多个线程同时读取一份共享变量,那没关系,随便读取。如果有一个线程要对它进行修改,那么这个线程会对这个变量进行拷贝;然后在它的拷贝对象上进行 *** 作, *** 作期间,其余线程看到的仍然是原对象,直到这个线程对它修改完毕才会写会主内存。

​ 这种思想适用于对共享变量的值准确性要求没那么高,允许读取过期的数据的情况。

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/zaji/5563343.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-14
下一篇 2022-12-14

发表评论

登录后才能评论

评论列表(0条)

保存