Java并发中的可见性和原子性

简介: Java并发中的可见性和原子性

一、可见性



1、实例讲解


先看这样一段代码:

public class Test {
  static boolean a = true;
  public static void main(String[] args) {
    a = false;          //对a执行写操作
    System.out.println(a);    //对a执行读操作
  }
}


f1d77287d3914ab9b8def869d22efd9c.png


我们在单线程中,对a执行了写操作,并且读取到了最新写的值,也就是说,单线程中对a的写操作时可见的。


那么我们再开启一个线程 :

public class Test {
  static boolean a = true;
  public static void main(String[] args) throws InterruptedException {
    new Thread(()-> {
      while(a) {} //死循环
    }).start();
    Thread.sleep(1000); //为了保证不会影响,停一秒再写    
    a = false;          //对a执行写操作
    System.out.println(a);    //对a执行读操作
  }
}


cc57a8af1dfd4bde82cdf8beb968d9e6.png


可以看到,虽然a仍然打印出为false,但是程序没有结束,就说明在我们新开启的线程中a的值始终为true,他才可以一直执行while循环。换句话说,我们的主线程对a的写操作对于新开的线程的读操作来说是不可见的。


为什么这么长时间了,新开线程中的a还是true呢?那是因为新线程中一直在执行循环,使得线程没有机会去拿到主存中a的最新值,而是一直读取缓存中a的值。


那么,我们让循环沉睡一会儿,给他去读最新值的机会:


public class Test {
  static boolean a = true;
  public static void main(String[] args) throws InterruptedException {
    new Thread(()-> {
      while(a) {
        try {
          Thread.sleep(1);  //睡1ms,给线程去读新值的机会
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      } //死循环
    }).start();
    Thread.sleep(1000); //为了保证不会影响,停一秒再写    
    a = false;          //对a执行写操作
    System.out.println(a);    //对a执行读操作
  }
}


91fe63e00df8484bb87b4c17bc5f255b.png


可以看到,程序很快就结束了,说明新线程已经读到了a的新值为false,结束了循环。


2、如何理解Java线程中的不可见性?


简单来说:线程1读,线程2写,而线程1读不到线程2写的值,这就是不可见性。


3、那么如何实现可见性呢?


就需要用到volatile关键字了:


public class Test {
  static volatile boolean a = true;
  public static void main(String[] args) throws InterruptedException {
    new Thread(()-> {
      while(a) {} //死循环
    }).start();
    Thread.sleep(1000); //为了保证不会影响,停一秒再写    
    a = false;          //对a执行写操作
    System.out.println(a);    //对a执行读操作
  }
}


a0a1288a0b0c486a8d48fb0338b06c0b.png


二、原子性



保证可见性可以保证一个线程写之后,另一个线程可以读到。


那假如一个进程既读取变量,又依赖读到的变量进行写操作呢?我们来看下面的例子 :


1、实例讲解


用两个线程分别执行10000次a++的操作,按道理来说,a的结果应该会增加20000


public class Test {
  static int a = 0;
  public static void main(String[] args) throws InterruptedException {
    for(int i=0; i<10000; i++) {
      new Thread(()-> {
        a++;
      }).start();
      new Thread(()-> {
        a++;
      }).start();
    }   
    Thread.sleep(1000); //为了保证不会影响,停一秒再写    
    System.out.println(a);    //对a执行读操作
  }
}


4ae4bc43f4ae4bd3a2297209007c4f4a.png

可以看到,结果和我们的预期对不上,那我们加上volatile关键字试一下:


bf020e6b3b124949bbb0a5b659f6db9b.pngd1b6dd17895245a38eee877778cea203.png

结果还是对不上,这是为什么呢?

这就要探究a++的本质了


2、a++的本质

     

a++可以拆分为三个操作:1、读取a; 2、a+1; 3、将加之后的值赋给a


有可能会出现这种情况:


       1、当a=0时,线程1读取a值,线程2也读取a值;

       2、线程1将它读到的a值+1,此时为1,线程2也将它读到的a值+1,此时为1;

       3、线程1将1这个值刷入主存,此时主存中的a=1;线程2也将1这个值刷入主存,此时为1


这显然是不对的,两个线程各执行一次a++,a的值应该+2才对。刚刚我们得到的值为19998,可能就是有两个线程在其他线程+之前读取到了a值。


那加上volatile关键字之后呢?


volatile关键字只能保证可见性,即一个线程写过之后,另一个线程能够立马读到。而假如线程2操作在线程1操作写之前就已经读了,那还是没办法改变这个情况。这两个线程都需要读取主存的值,并且每个线程都依赖自己读取的值进行写操作。这就需要保证原子性了。


3、使用Atomicxxxx保证原子性:


public class Test {
  static AtomicInteger a = new AtomicInteger(0);
  public static void main(String[] args) throws InterruptedException {
    for(int i=0; i<10000; i++) {
      new Thread(()-> {
        a.getAndAdd(1);
      }).start();
      new Thread(()-> {
        a.getAndAdd(1);
      }).start();
    }   
    Thread.sleep(1000); //为了保证不会影响,停一秒再写    
    System.out.println(a);    //对a执行读操作
  }
}


540047ec940743c988600d02d3a63dd0.png

可以看到,a++的操作改成了getAndAdd(),读和写是一起执行的,这就不会在读值之后写值之前被其他线程插一杠子。


需要注意的一点是,原子性和可见性并不是相互独立的,保证原子性的前提是保证可见性,那为什么我们没有再用volatile修饰a来保证可见性呢?这就需要去看看AtomicInteger的源码了:

6e7d51c461154ee2af35713e6192ec2f.png


其实它的内部也使用了volatile关键字。


4、使用synchronized同步代码段强制实现原子性和可见性


除了Atomic,也可以使用synchronized同步代码段强制实现原子性。


public class Test {
  static AtomicInteger a = new AtomicInteger(0);
  static int b = 0;
  public static void main(String[] args) throws InterruptedException {
    for(int i=0; i<10000; i++) {
      new Thread(()-> {
//        a.getAndAdd(1);
        synchronized(Test.class) {
          b++;
        }
      }).start();
      new Thread(()-> {
//        a.getAndAdd(1);
        synchronized(Test.class) {
          b++;
        }
      }).start();
    }   
    Thread.sleep(1000); //为了保证不会影响,停一秒再写    
//    System.out.println(a);    //对a执行读操作
    System.out.println(b);    //对b执行读操作
  }
}


相比较Atomic,synchronized就更加重量级了。


另外:volatile不具有传染性,用volatile修饰的对象的内部属性不具有可见性,反之用volatile修饰的内部属性也不能保证所在对象的可见性。


参考:【java】并发之可见性与原子性_哔哩哔哩_bilibili 




相关文章
|
8天前
|
安全 Java Go
Java vs. Go:并发之争
【4月更文挑战第20天】
25 1
|
8天前
|
数据采集 存储 Java
高德地图爬虫实践:Java多线程并发处理策略
高德地图爬虫实践:Java多线程并发处理策略
|
5天前
|
算法 Java 程序员
Java中的线程同步与并发控制
【5月更文挑战第18天】随着计算机技术的不断发展,多核处理器的普及使得多线程编程成为提高程序性能的关键。在Java中,线程是实现并发的一种重要手段。然而,线程的并发执行可能导致数据不一致、死锁等问题。本文将深入探讨Java中线程同步的方法和技巧,以及如何避免常见的并发问题,从而提高程序的性能和稳定性。
|
5天前
|
安全 Java 容器
Java一分钟之-并发编程:并发容器(ConcurrentHashMap, CopyOnWriteArrayList)
【5月更文挑战第18天】本文探讨了Java并发编程中的`ConcurrentHashMap`和`CopyOnWriteArrayList`,两者为多线程数据共享提供高效、线程安全的解决方案。`ConcurrentHashMap`采用分段锁策略,而`CopyOnWriteArrayList`适合读多写少的场景。注意,`ConcurrentHashMap`的`forEach`需避免手动同步,且并发修改时可能导致`ConcurrentModificationException`。`CopyOnWriteArrayList`在写操作时会复制数组。理解和正确使用这些特性是优化并发性能的关键。
11 1
|
5天前
|
安全 Java 容器
Java一分钟之-高级集合框架:并发集合(Collections.synchronizedXXX)
【5月更文挑战第18天】Java集合框架的`Collections.synchronizedXXX`方法可将普通集合转为线程安全,但使用时需注意常见问题和易错点。错误的同步范围(仅同步单个操作而非迭代)可能导致并发修改异常;错误地同步整个集合类可能引起死锁;并发遍历和修改集合需使用`Iterator`避免`ConcurrentModificationException`。示例代码展示了正确使用同步集合的方法。在复杂并发场景下,推荐使用`java.util.concurrent`包中的并发集合以提高性能。
18 3
|
8天前
|
存储 安全 算法
掌握Java并发编程:Lock、Condition与并发集合
掌握Java并发编程:Lock、Condition与并发集合
16 0
|
8天前
|
Java
Java并发Futures和Callables类
Java程序`TestThread`演示了如何在多线程环境中使用`Futures`和`Callables`。它创建了一个单线程`ExecutorService`,然后提交两个`FactorialService`任务,分别计算10和20的阶乘。每个任务返回一个`Future`对象,通过`get`方法获取结果,该方法会阻塞直到计算完成。计算过程中模拟延迟以展示异步执行。最终,打印出10!和20!的结果。
25 10
|
8天前
|
安全 Java
Java中的并发编程:理解并发性与线程安全
Java作为一种广泛应用的编程语言,在并发编程方面具有显著的优势和特点。本文将探讨Java中的并发编程概念,重点关注并发性与线程安全,并提供一些实用的技巧和建议,帮助开发人员更好地理解和应用Java中的并发机制。
|
8天前
|
存储 安全 Java
【亮剑】`ConcurrentHashMap`是Java中线程安全的哈希表,采用锁定分离技术提高并发性能
【4月更文挑战第30天】`ConcurrentHashMap`是Java中线程安全的哈希表,采用锁定分离技术提高并发性能。数据被分割成多个Segment,每个拥有独立锁,允许多线程并发访问不同Segment。当写操作发生时,计算键的哈希值定位Segment并获取其锁;读操作通常无需锁定。内部会根据负载动态调整Segment,减少锁竞争。虽然使用不公平锁,但Java 8及以上版本提供了公平锁选项。理解其工作原理对开发高性能并发应用至关重要。
|
8天前
|
存储 Java 索引
【亮剑】Java中的并发容器ConcurrentHashMap,它在JDK1.5中引入,用于替换HashTable和SynchronizedMap
【4月更文挑战第30天】本文介绍了Java中的并发容器ConcurrentHashMap,它在JDK1.5中引入,用于替换HashTable和SynchronizedMap。文章展示了创建、添加、获取、删除和遍历元素的基本用法。ConcurrentHashMap的内部实现基于分段锁,每个段是一个独立的Hash表,通过分段锁实现并发控制。每个段内部采用数组+链表/红黑树的数据结构,当冲突过多时转为红黑树优化查询。此外,它有扩容机制,当元素超过阈值时,会逐段扩容并翻倍Segment数量,以保持高性能的并发访问。