暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

并发编程三要素:原子性,有序性,可见性

Whoooops 2021-04-16
1994

当多个线程要共享一个实例对象的值的时候,那么在考虑安全的多线程并发编程时就要保证下面3个要素。

  1. 原子性(Synchronized, Lock)一个或某几个操作只能在一个线程执行完之后,另一个线程才能开始执行该操作,也就是说这些操作是不可分割的,线程不能在这些操作上交替执行。

  2. 可见性(Volatile,Synchronized,Lock)一个线程对共享变量的修改,另一个线程能够立刻看到。

  3. 有序性(Volatile,Synchronized, Lock)程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排)。

当完成了并发三要素后,自然也就解决了线程安全问题。

线程安全问题:当多个线程访问某个方法时,不管你通过怎样的调用方式、或者说这些线程如何交替地执行,我们在主程序中不需要去做任何的同步,这个类的结果行为都是我们设想的正确行为,那么我们就可以说这个类是线程安全的。

原子性:

看一个例子:

i++;

它相当于三个原子性操作:

1.读取变量i的值;

2.将变量i的值加1;

3.将结果写入变量i中。

1.线程1执行自增方法时先读取i的值,发现是5,此时切换到线程2执行自增方法时,读取到变量i的值也是5,

2.线程1执行将变量i的值加1的操作,线程2也执行此操作,

3.线程1将结果赋给变量i,线程2也将结果赋给变量i。

这两个线程都执行了一次自增方法之后,最后的结果都是i从5变到了6,而不是我们想要的7....

由于CPU的速度非常快,这种交叉执行在执行次数较低的时候体现的并不明显,但是在执行次数非常多的时候,就十分明显了。也就是说,线程的切换会带来原子性的问题。

如何解决原子性问题?

1.尽量使用局部变量,局部变量是存储在栈内存的。线程是私有的,局部变量和方法是不可共享的。

    public void threadMethod(int j) {
        int i = 1;
    j = j + i;
    }

    这段代码就不会出现线程安全问题,两个线程同时访问这个方法,因为没有共享的数据,所以他们之间的行为,并不会影响其他线程的操作和结果。

    2.加锁 synchronized 或者 lock

    synchronized 代码举例:

      public class ThreadDemo {
         int count = 0// 记录方法的命中次数
         public synchronized void threadMethod(int j) {
             count++ ;
             int i = 1;
      j = j + i;
      }
      }

      对于成员方法来说,我们可以直接用this作为锁。

      对于静态方法来说,我们可以直接用Class对象作为锁

      lock代码举例

        private Lock lock = new ReentrantLock(); // ReentrantLock是Lock的子类
        private void method(Thread thread){
        lock.lock(); // 获取锁对象
        try {
        System.out.println("线程名:"+thread.getName() + "获得了锁");
        // Thread.sleep(2000);
        }catch(Exception e){
        e.printStackTrace();
        } finally {
        System.out.println("线程名:"+thread.getName() + "释放了锁");
        lock.unlock(); // 释放锁对象
        }
        }

        lock和synchronized区别:一个是手动挡一个是自动档。手动挡的自由度更大,lock可以有更多的操作,像是trylock。Lock在获取锁的时候,如果拿不到锁,就一直处于等待状态,直到拿到锁,但是tryLock()却不是这样的,tryLock是有一个Boolean的返回值的,如果没有拿到锁,直接返回false,停止等待,它不会像Lock()那样去一直等待获取锁。

        可见性:

        缓存导致的可见性

        举个栗子:

          int v = 0;
          // 线程 A 执行
          v=v+1  ; 
          // 线程 B 执行
          System.out.print("v=" v);

          即使是在执行完线程里的 v后再执行线程 B,线程 B 的输入结果也会有 2 个种情况,一个是 0 和1。

          因为 i 在线程 A(CPU-1)中做完了运算,并没有立刻更新到主内存当中,而线程B(CPU-2)就去主内存当中读取并打印,此时打印的就是 0。

          如何保证可见性

          禁用缓存能保证可见性,volatile关键字可以禁用缓存。

          当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取共享变量时,它会去内存中读取新值。

          普通的共享变量不能保证可见性,因为普通共享变量被修改后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

          更新主存的步骤:当前线程将其他线程的工作内存中的缓存变量的缓存行设置为无效,然后当前线程将变量的值跟新到主存,更新成功后将其他线程的缓存行更新为新的主存地址

          其他线程读取变量时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。

          有序性

          导致有序性的原因是编译优化

          我们都知道处理器为了拥有更好的运算效率,会自动优化、排序执行我们写的代码,但会确保执行结果不变。

          例子:

            int a = 0; // 语句 1
            int b = 0; // 语句 2
            i ; // 语句 3
            b ; // 语句 4

            这一段代码的执行顺序很有可能不是按上面的 1、2、3、4 来依次执行,因为 1 和 2 没有数据依赖,3 和 4 没有数据依赖, 2、1、4、3 这样来执行可以吗?完全没问题,处理器会自动帮我们排序。

            在单线程看来并没有什么问题,但在多线程则很容易出现问题。

            再来个例子:

              // 线程 1
              init();
              inited = true;
              // 线程 2
              while(inited){
              work();
              }

              init(); 与 inited = true; 并没有数据的依赖,在单线程看来,如果把两句的代码调换好像也不会出现问题。

              但此时处于一个多线程的环境,而处理器真的把这两句代码重新排序,那问题就出现了,若线程 1 先执行 inited = true; 此时,init() 并没有执行,线程 2 就已经开始调用 work() 方法,此时很可能造成一些奔溃或其他 BUG 的出现。

              如何解决?

              volatile关键字可以解决这个问题,volatile 关键字可以保证有序性,让处理器不会把这行代码进行优化排序

              文章转载自Whoooops,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

              评论