二、并发编程的三大特性

分类:N07_Java

标签:

二、并发编程的三大特性

一、原子性

1.1 什么是并发编程的原子性

JMM(Java Memory Model)。不同的硬件和不同的操作系统在内存上的操作有一定差异的。Java为了解决相同代码在不同操作系统上出现的各种问题,用JMM屏蔽掉各种硬件和操作系统带来的差异。

让Java的并发编程可以做到跨平台。

JMM规定所有变量都会存储在主内存中,在操作的时候,需要从主内存中复制一份到线程内存(CPU内存),在线程内部做计算。然后再写回主内存中(不一定!)。

原子性的定义:原子性指一个操作是不可分割的,不可中断的,一个线程在执行时,另一个线程不会影响到他。

并发编程的原子性用代码阐述:


private static int count;

public static void increment(){
   try {
       Thread.sleep(10);
   } catch (InterruptedException e) {
       e.printStackTrace();
   }
   count++;
}

public static void main(String[] args) throws InterruptedException {
   Thread t1 = new Thread(() -> {
       for (int i = 0; i < 100; i++) {
          increment();
       }
   });
   Thread t2 = new Thread(() -> {
       for (int i = 0; i < 100; i++) {
           increment();
       }
   });
   t1.start();
   t2.start();
   t1.join();
   t2.join();
   System.out.println(count);
}

当前程序:多线程操作共享数据时,预期的结果,与最终的结果不符。

原子性:多线程操作临界资源,预期的结果与最终结果一致。

通过对这个程序的分析,可以查看出,++的操作,一共分为了三部,首先是线程从主内存拿到数据保存到CPU的寄存器中,然后在寄存器中进行+1操作,最终将结果写回到主内存当中。

1.2 保证并发编程的原子性

1.2.1 synchronized

因为++操作可以从指令中查看到

可以在方法上追加synchronized关键字或者采用同步代码块的形式来保证原子性

synchronized可以让避免多线程同时操作临街资源,同一时间点,只会有一个线程正在操作临界资源

1.2.2 CAS

到底什么是CAS

compare and swap也就是比较和交换,他是一条CPU的并发原语。

他在替换内存的某个位置的值时,首先查看内存中的值与预期值是否一致,如果一致,执行替换操作。这个操作是一个原子性操作。

Java中基于Unsafe的类提供了对CAS的操作的方法,JVM会帮助我们将方法实现CAS汇编指令。

但是要清楚CAS只是比较和交换,在获取原值的这个操作上,需要你自己实现。

private static AtomicInteger count = new AtomicInteger(0);

public static void main(String[] args) throws InterruptedException {
   Thread t1 = new Thread(() -> {
       for (int i = 0; i < 100; i++) {
           count.incrementAndGet();
       }
   });
   Thread t2 = new Thread(() -> {
       for (int i = 0; i < 100; i++) {
           count.incrementAndGet();
       }
   });
   t1.start();
   t2.start();
   t1.join();
   t2.join();
   System.out.println(count);
}

Doug Lea在CAS的基础上帮助我们实现了一些原子类,其中就包括现在看到的AtomicInteger,还有其他很多原子类……

CAS的缺点:CAS只能保证对一个变量的操作是原子性的,无法实现对多行代码实现原子性。

CAS的问题

1.2.3 Lock锁

Lock锁是在JDK1.5由Doug Lea研发的,他的性能相比synchronized在JDK1.5的时期,性能好了很多多,但是在JDK1.6对synchronized优化之后,性能相差不大,但是如果涉及并发比较多时,推荐ReentrantLock锁,性能会更好。

实现方式:

private static int count;

private static ReentrantLock lock = new ReentrantLock();

public static void increment()  {
   lock.lock();
   try {
       count++;
       try {
           Thread.sleep(10);
       } catch (InterruptedException e) {
           e.printStackTrace();
       }
   } finally {
       lock.unlock();
   }


}

public static void main(String[] args) throws InterruptedException {
   Thread t1 = new Thread(() -> {
       for (int i = 0; i < 100; i++) {
           increment();
       }
   });
   Thread t2 = new Thread(() -> {
       for (int i = 0; i < 100; i++) {
           increment();
       }
   });
   t1.start();
   t2.start();
   t1.join();
   t2.join();
   System.out.println(count);
}

ReentrantLock可以直接对比synchronized,在功能上来说,都是锁。

但是ReentrantLock的功能性相比synchronized更丰富。

ReentrantLock底层是基于AQS实现的,有一个基于CAS维护的state变量来实现锁的操作。

1.2.4 ThreadLocal

Java中的四种引用类型

Java中的使用引用类型分别是强,软,弱,虚

User user = new User();

在 Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它始终处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到 JVM 也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之一。


SoftReference

其次是软引用,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中,作为缓存使用。

然后是弱引用,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。可以解决内存泄漏问题,ThreadLocal就是基于弱引用解决内存泄漏的问题。

最后是虚引用,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。不过在开发中,我们用的更多的还是强引用。

ThreadLocal保证原子性的方式,是不让多线程去操作临界资源,让每个线程去操作属于自己的数据

代码实现

static ThreadLocal tl1 = new ThreadLocal();
static ThreadLocal tl2 = new ThreadLocal();

public static void main(String[] args) {
   tl1.set("123");
   tl2.set("456");
   Thread t1 = new Thread(() -> {
       System.out.println("t1:" + tl1.get());
       System.out.println("t1:" + tl2.get());
   });
   t1.start();

   System.out.println("main:" + tl1.get());
   System.out.println("main:" + tl2.get());
}

ThreadLocal实现原理:

ThreadLocal内存泄漏问题:

二、可见性

2.1 什么是可见性

可见性问题是基于CPU位置出现的,CPU处理速度非常快,相对CPU来说,去主内存获取数据这个事情太慢了,CPU就提供了L1,L2,L3的三级缓存,每次去主内存拿完数据后,就会存储到CPU的三级缓存,每次去三级缓存拿数据,效率肯定会提升。

这就带来了问题,现在CPU都是多核,每个线程的工作内存(CPU三级缓存)都是独立的,会告知每个线程中做修改时,只改自己的工作内存,没有及时的同步到主内存,导致数据不一致问题。

可见性问题的代码逻辑

private static boolean flag = true;

public static void main(String[] args) throws InterruptedException {
   Thread t1 = new Thread(() -> {
       while (flag) {
           // ....
       }
       System.out.println("t1线程结束");
   });

   t1.start();
   Thread.sleep(10);
   flag = false;
   System.out.println("主线程将flag改为false");
}

2.2 解决可见性的方式

2.2.1 volatile

volatile是一个关键字,用来修饰成员变量。

如果属性被volatile修饰,相当于会告诉CPU,对当前属性的操作,不允许使用CPU的缓存,必须去和主内存操作

volatile的内存语义:

其实加了volatile就是告知CPU,对当前属性的读写操作,不允许使用CPU缓存,加了volatile修饰的属性,会在转为汇编之后后,追加一个lock的前缀,CPU执行这个指令时,如果带有lock前缀会做两个事情:

总结:volatile就是让CPU每次操作这个数据时,必须立即同步到主内存,以及从主内存读取数据。

private volatile static boolean flag = true;

public static void main(String[] args) throws InterruptedException {
   Thread t1 = new Thread(() -> {
       while (flag) {
           // ....
       }
       System.out.println("t1线程结束");
   });

   t1.start();
   Thread.sleep(10);
   flag = false;
   System.out.println("主线程将flag改为false");
}

2.2.2 synchronized

synchronized也是可以解决可见性问题的,synchronized的内存语义。

如果涉及到了synchronized的同步代码块或者是同步方法,获取锁资源之后,将内部涉及到的变量从CPU缓存中移除,必须去主内存中重新拿数据,而且在释放锁之后,会立即将CPU缓存中的数据同步到主内存。

private static boolean flag = true;

public static void main(String[] args) throws InterruptedException {
   Thread t1 = new Thread(() -> {
       while (flag) {
           synchronized (MiTest.class){
               //...
           }
           System.out.println(111);
       }
       System.out.println("t1线程结束");

   });

   t1.start();
   Thread.sleep(10);
   flag = false;
   System.out.println("主线程将flag改为false");
}

2.2.3 Lock

Lock锁保证可见性的方式和synchronized完全不同,synchronized基于他的内存语义,在获取锁和释放锁时,对CPU缓存做一个同步到主内存的操作。

Lock锁是基于volatile实现的。Lock锁内部再进行加锁和释放锁时,会对一个由volatile修饰的state属性进行加减操作。

如果对volatile修饰的属性进行写操作,CPU会执行带有lock前缀的指令,CPU会将修改的数据,从CPU缓存立即同步到主内存,同时也会将其他的属性也立即同步到主内存中。还会将其他CPU缓存行中的这个数据设置为无效,必须重新从主内存中拉取。


private static boolean flag = true;
private static Lock lock = new ReentrantLock();

public static void main(String[] args) throws InterruptedException {
   Thread t1 = new Thread(() -> {
       while (flag) {
           lock.lock();
           try{
               //...
           }finally {
               lock.unlock();
           }
       }
       System.out.println("t1线程结束");

   });

   t1.start();
   Thread.sleep(10);
   flag = false;
   System.out.println("主线程将flag改为false");
}

2.2.4 final

final修饰的属性,在运行期间是不允许修改的,这样一来,就间接的保证了可见性,所有多线程读取final属性,值肯定是一样。

final并不是说每次取数据从主内存读取,他没有这个必要,而且final和volatile是不允许同时修饰一个属性的

final修饰的内容已经不允许再次被写了,而volatile是保证每次读写数据去主内存读取,并且volatile会影响一定的性能,就不需要同时修饰。


修改内容