并发编程基础

Posted jianqiang111

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发编程基础相关的知识,希望对你有一定的参考价值。

synchronized 原理分析
synchronized关键字解决的是多个线程之间访问资源的同步性问题,synchronized关键字 可以保证被它修饰的⽅法或者代码块在任意时刻只能有⼀个线程执⾏。
jdk1.6之前性能⽐较低,Java 的线程是映射到操作系统的原⽣线程之上的。如果要挂起或者唤醒⼀个线程,都需要操作系统帮忙完成,⽽操作系统实现线程之间的切换时需要从⽤户态转换到内核态,这个状态之间的转换需要相对⽐较⻓的时间,时间成本相对较⾼。
JDK1.6对锁的实现引⼊了⼤量的优化,如⾃旋锁、适应性⾃旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
 
如何使⽤ synchronized
  • 修饰实例⽅法: 作⽤于当前对象实例加锁,进⼊同步代码前要获得当前对象实例的锁
1 /**
2  * synchronized 修饰实例⽅法
3  */
4  public synchronized void increase() {
5  i++;
6  }
  • 修饰静态⽅法: 也就是给当前类加锁,会作⽤于类的所有对象实例,因为静态成员不属于任何⼀个实例对象,是类成员( static 表明这是该类的⼀个静态资源,不管new了多少个对象,只有⼀份)。
 1 public class Demo21 {
 2     public static int i;
 3     private synchronized static void method() {
 4         System.out.println("我是类锁的第⼀种形式:static形式,我叫" + Thread.currentThread().getName());
 5         try {
 6             Thread.sleep(1000);
 7             for (int j = 0; j < 100000; j++) {
 8                 i++;
 9             }
10         } catch (InterruptedException e) {
11             e.printStackTrace();
12         }
13         System.out.println(Thread.currentThread().getName() + "1=" + i);
14     }
15 }
  • 修饰代码块: 指定加锁对象,对给定对象加锁,进⼊同步代码库前要获得给定对象的锁。
 1 private void method2() {
 2         synchronized (Demo21.class) {
 3 // synchronized (this) {
 4             System.out.println("我是类锁的第⼀种形式:static形式,我叫" +
 5                     Thread.currentThread().getName());
 6             try {
 7                 Thread.sleep(1000);
 8                 for (int j = 0; j < 100000; j++) {
 9                     i++;
10                 }
11             } catch (InterruptedException e) {
12                 e.printStackTrace();
13             }
14             System.out.println(Thread.currentThread().getName() + "1=" + i);
15         }
16     }
synchronized 关键字的底层原理
  • 同步代码块的情况

     ⾸先切换到类的对应⽬录执⾏ javac XXX.java 命令⽣成编译后的.class ⽂件,然后执⾏ javap -c -s -v -l XXX.class。

    synchronized 同步语句块的实现使⽤的是 monitorenter 和 monitorexit 指令,其中monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执⾏ monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于                    每个Java对象的对象头中,synchronized 锁便是通过这种⽅式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。 当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执⾏monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外⼀个线程释放为⽌。

  • 同步方法的情况

    ⾸先切换到类的对应⽬录执⾏ javac XXX.java 命令⽣成编译后的 .class ⽂件,然后执⾏ javap -c -s -v -l XXX.class。

     synchronized 修饰的⽅法并没有 monitorenter 指令和 monitorexit 指令,取得代之的却是ACC_SYNCHRONIZED 标识,该标识指明了该⽅法是⼀个同步⽅法,JVM 通过该ACC_SYNCHRONIZED 访问标志来辨别⼀个⽅法是否声明为同步⽅法,从⽽执⾏相应的同

步调⽤。
 
synchronized JDK1.6之后的性能优化
锁主要存在四中状态,依次是:⽆锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈⽽逐渐升级。注意锁可以升级不可降级,这种策略是为了提⾼获得锁和释放锁的效率。
  • 偏向锁

    引⼊偏向锁的⽬的和引⼊轻量级锁的⽬的很像,他们都是为了没有多线程竞争的前提下,减少传统的重量级锁使⽤操作系统互斥量产⽣的性能消耗。但是不同是:轻量级锁在⽆竞争的情况下使⽤CAS 操作去代替使⽤互斥量。⽽偏向锁在⽆竞争的情况下会把整个同步都消除掉。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,竞争成果后为轻量级锁如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

  • 轻量级锁
    倘若偏向锁失败,虚拟机并不会⽴即升级为重量级锁,它还会尝试使⽤⼀种称为轻量级锁的优化⼿段(1.6之后加⼊的)。轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使⽤操作系统互斥量产⽣的性能消耗,因为使⽤轻量级锁时,不需要申请互斥量。另外,轻量级锁的加锁和解锁都⽤到了CAS操作。
  • 自旋锁和自适应自旋    

    轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层⾯挂起,还会进⾏⼀项称为⾃旋锁的优化⼿段。

    互斥同步对性能最⼤的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要转⼊内核态中完成(⽤户态转换到内核态会耗费时间)。

    ⼀般线程持有锁的时间都不是太⻓,所以仅仅为了这⼀点时间去挂起线程/恢复线程是得不偿失的。 所以,虚拟机的开发团队就这样去考虑:“我们能不能让后⾯来的请求获取锁的线程等待⼀会⽽不被挂起呢?看看持有锁的线程是否很快就会释放锁”。为了让⼀个线程等待,我们只需要让线程执⾏⼀个忙循环(⾃旋),这项技术就叫做⾃旋。而自适应自旋就是自旋的次数不是固定的,是自适应的。
  • 锁消除

    锁消除理解起来很简单,它指的就是虚拟机即使编译器在运⾏时,如果检测到那些共享数据不可能存在竞争,那么就执⾏锁消除。锁消除可以节省毫⽆意义的请求锁的时间。

  • 锁粗化

    原则上,我们在编写代码的时候,总是推荐将同步块的作⽤范围限制得尽量⼩,——直在共享数据的实际作⽤域才进⾏同步,这样是为了使得需要同步的操作数量尽可能变⼩,如果存在锁竞争,那等待线程也能尽快拿到锁。

    ⼤部分情况下,上⾯的原则都是没有问题的,但是如果⼀系列的连续操作都对同⼀个对象反复加锁和解锁,那么会带来很多不必要的性能消耗。
 
Synchronized 和 ReenTrantLock 的对⽐
  • 两者都是可重⼊锁
  • synchronized 依赖于 JVM ⽽ ReenTrantLock 依赖于 API
  • ReenTrantLock ⽐ synchronized 增加了⼀些⾼级功能
    相⽐synchronized,ReenTrantLock增加了⼀些⾼级功能。主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)
    • ReenTrantLock提供了⼀种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
    • ReenTrantLock可以指定是公平锁还是⾮公平锁。⽽synchronized只能是⾮公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReenTrantLock默认情况是⾮公平的,可以通过ReenTrantLock类的 ReentrantLock(boolean fair) 构造⽅法来制定是否是公平的。
    • synchronized关键字与wait()和notify/notifyAll()⽅法相结合可以实现等待/通知机制,ReentrantLock类当然也可以实现,但是需要借助于Condition接⼝与newCondition() ⽅法。Condition是JDK1.5之后才有的,它具有很好的灵活性,⽐如可以实现多路通知功能也就是在⼀个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从⽽可以有选择性的进⾏线程通知,在调度线程上更加灵活。 在使⽤notify/notifyAll()⽅法进⾏通知时,被通知的线程是由 JVM 选择的,⽤ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能⾮常重要,⽽且是Condition接⼝默认提供的。⽽synchronized关键字就相当于整个Lock对象中只有⼀个Condition实例,所有的线程都注册在它⼀个身上。如果执⾏notifyAll()⽅法的话就会通知所有处于等待状态的线程这样会造成很⼤的效率问题,⽽Condition实例的signalAll()⽅法 只会唤醒注册在该Condition实例中的所有等待线程。
volatile原理分析
volatile 介绍
Java内存模型告诉我们,各个线程会将共享变量从主内存中拷⻉到⼯作内存,然后执⾏引擎会基于⼯作内存中的数据进⾏操作处理。线程在⼯作内存进⾏操作后何时会写到主内存中?这个时机对普通变量是没有规定的,⽽针对volatile修饰的变量给java虚拟机特殊的约定,线程对volatile变量的修改会⽴刻被其他线程所感知,即不会出现数据脏读的现象,从⽽保证数据的“可⻅性”。
被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从⽽避免出现数据脏读的现象。
 
volatile实现原理
在⽣成汇编代码时会在volatile修饰的共享变量进⾏写操作的时候会多出Lock前缀的指令。 我们想这个Lock指令肯定有神奇的地⽅,那么Lock前缀的指令在多核处理器下会发现什么事情了?主要有这两个⽅⾯的影响:
  1. 将当前处理器缓存⾏的数据写回系统内存;
  2. 这个写回内存的操作会使得其他CPU⾥缓存了该内存地址的数据⽆效
如果对声明了volatile的变量进⾏写操作,JVM就会向处理器发送⼀条Lock前缀的指令,将这个变量所在缓存⾏的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执⾏计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是⼀致的,就会实现缓存⼀致性协议,每个处理器通过嗅探在总线上传播的数据来检查⾃⼰缓存的值是不是过期**了,当处理器发现⾃⼰缓存⾏对应的内存地址被修改,就会将当前处理器的缓存⾏设置成⽆效状态,当处理器对这个数据进⾏修改操作的时候,会重新从系统内存中把数据读到处 理器缓存⾥。因此,经过分析我们可以得出如下
结论:
1. Lock前缀的指令会引起处理器缓存写回内存;
2. ⼀个处理器的缓存回写到内存会导致其他处理器的缓存失效;
3. 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最 新值。
这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。

 

volatile与可⻅性
可⻅性是指当多个线程访问同⼀个变量时,⼀个线程修改了这个变量的值,其他线程能够⽴即看得到修改的值。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有⾃⼰的⼯作内存,线程的⼯作内存中保存了该线程中是⽤到的变量的主内存副本拷⻉,线程对变量的所有操作都必须在⼯作内存中进⾏,⽽不能直接读写主内存。不同的线程之间也⽆法直接访问对⽅⼯作内存中的变量,线程间变量的传递均需要⾃⼰的⼯作内存和主存之间进⾏数据同步进⾏。所以,就可能出现线程1改了某个变量的值,但是线程2不可⻅的情况。
Java中的volatile关键字提供了⼀个功能,那就是被其修饰的变量在被修改后可以⽴即同步到主内存,被其修饰的变量在每次是⽤之前都从主内存刷新。因此,可以使⽤volatile来保证多线程操作时变量的可⻅性。

 

volatile与内存屏障
我们都知道,为了性能优化,JMM(java内存模型)在不改变正确语义的前提下,会允许编译器和处理器对指令序列进⾏重排序,那如果想阻⽌重排序要怎么办了?答案是可以添加内存屏障。JMM内存屏障分为四类:
StoreStore屏障:禁⽌上⾯的普通写和下⾯的volatile写重排序;
StoreLoad屏障:防⽌上⾯的volatile写与下⾯可能有的volatile读/写重排序;
LoadLoad屏障:禁⽌下⾯所有的普通读操作和上⾯的volatile读重排序;
LoadStore屏障:禁⽌下⾯所有的普通写操作和上⾯的volatile读重排序;

 

 

为了实现volatile内存语义时,编译器在⽣成字节码时,会在指令序列中插⼊内存屏障来禁⽌特定类型的处理器重排序
1. 在每个volatile写操作的前⾯插⼊⼀个StoreStore屏障;
2. 在每个volatile写操作的后⾯插⼊⼀个StoreLoad屏障;
3. 在每个volatile读操作的后⾯插⼊⼀个LoadLoad屏障;
4. 在每个volatile读操作的后⾯插⼊⼀个LoadStore屏障。
需要注意的是:volatile写是在前⾯和后⾯分别插⼊内存屏障,⽽volatile读操作是在后⾯插⼊两个内存屏障

 

 

 

不可变对象

让并发编程变得更简单
说到并发编程,可能很多朋友都会觉得最苦恼的事情就是如何处理共享资源的互斥访问,可能稍不留神,就会导致代码上线后出现莫名其妙的问题,⼤多数情况下,对于资源互斥访问的场景,都是采⽤加锁的⽅式来实现对资源的串⾏访问, 来保证并发安全,如synchronize关键字,Lock锁等。但是这种⽅案最⼤的⼀个难点在于:
在进⾏加锁和解锁时需要⾮常地慎重。如果加锁或者解锁时机稍有⼀点偏差,就可能会引发重⼤问题,然⽽这个问题Java编译器⽆法发现。既然采⽤串⾏⽅式来访问共享资源这么容易出现问题,那么有没有其他办法来解决呢?
事实上,引起线程安全问题的根本原因在于:多个线程需要同时访问同⼀个共享资源。 假如没有共享资源,那么多线程安全问题就⾃然解决了,Java中提供的ThreadLocal机制就是采取的这种思想。
然⽽⼤多数时候,线程间是需要使⽤共享资源互通信息的,如果共享资源在创建之后就完全不再变更,如同⼀个常量,⽽多个线程间并发读取该共享资源是不会存在线上安全问题的,因为所有线程⽆论何时读取该共享资源,总是能获取到⼀致的、完整的资源状态。
不可变对象就是这样⼀种在创建之后就不再变更的对象,这种特性使得它们天⽣⽀持线程安全,让并发编程变得更简单。
很多时候⼀些很严重的bug是由于⼀个很⼩的副作⽤引起的,并且由于副作⽤通常不容易被察觉,所以很难在编写代码以及代码review过程中发现,并且即使发现了也可能会花费很⼤的精⼒才能定位出来。
 
如何创建不可变对象
  • 所有成员变量必须是private
  • 最好同时⽤final修饰(⾮必须)
  • 不提供能够修改原有对象状态的⽅法
    • 最常⻅的⽅式是不提供setter⽅法
    • 如果提供修改⽅法,需要新创建⼀个对象,并在新创建的对象上进⾏修改
  • getter⽅法不能对外泄露this引⽤以及成员变量的引⽤
Collections.unmodifiableXXX
JDK中提供了⼀系列⽅法⽅便我们创建不可变集合
  • java.util.Collections#unmodifiableCollection
  • java.util.Collections#unmodifiableSet
  • java.util.Collections#unmodifiableSortedSet
  • java.util.Collections#unmodifiableNavigableSet
  • java.util.Collections#unmodifiableList
  • java.util.Collections#unmodifiableMap
  • java.util.Collections#unmodifiableSortedMap
  • java.util.Collections#unmodifiableNavigableMap
Guava

 

 

 
final
  • 修饰类(禁⽌继承)
  • 修饰⽅法(禁⽌⼦类覆盖,注意现在已经不需要因为效率把⽅法设置为final了)
  • final修饰的变量称为常量
  • final修饰的引⽤类型变量 是引⽤不可变,⾮对象不可变
 
线程不安全类、并发容器
线程不安全类 线程安全类
StringBuilder StringBuffer
SimpleDateFormat JodaTime
ArrayList CopyOnWriteArrayList
HashSet,TreeSet  CopyOnWriteArraySet,ConcurrentSkipListSet
HashMap,TreeMap ConcurrentHashMap,ConcurrentSkipListMap
。。。 。。。
COW写时复制(CopyOnWriteArrayList,CopyOnWriteArraySet)
写⼊时复制(CopyOnWrite)思想写⼊时复制(CopyOnWrite,简称COW)思想是计算机程序设计领域中的⼀种优化策略。 其核⼼思想是,如果有多个调⽤者(Callers)同时要求相同的资源(如内存或者是磁盘上 的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调⽤者视图修改资源内容时,系统才会真正复制⼀份专⽤副本(private copy)给该调⽤者,⽽其他调⽤者所⻅到的最初的资源仍然保持不变。这过程对其他的调⽤者都是透明的(transparently)。此做法主要的优点是如果调⽤者没有修改资源,就不会有副本(private copy)被创建,因此多个调⽤者只是读取操作时可以共享同⼀份资源。
  • CopyOnWriteArrayList

    实现原理:CopyOnWriterArrayList 允许并发的读,读操作是⽆锁的,性能较⾼。写操作的话,⽐如向容器增加⼀个元素,则⾸先将当前容器复制⼀份,然后在新副本上执⾏写操作,结束之后再将原容器的引⽤指向新容器。

优点:读操作性能很⾼,因为⽆需任何同步措施,⽐较适⽤于读多写少的并发场景。Java 的 list 在遍历时,若中途有其他线程对容器进⾏修改,则会抛出ConcurrentModificationException 异常。⽽CopyOnWriteArrayList由于其“读写分离”的思想,遍历和修改操作分别作⽤在不同的 list容器,所以迭代的时候不会抛出 ConcurrentModificationExecption 异常了。
缺点:
缺点也很明显,⼀是内存占⽤问题,毕竟每次执⾏写操作都要将原容器拷⻉⼀份,数据量⼤时,对内存压⼒较⼤,甚⾄可能引起频繁GC,⼆是⽆法保证实时性,Vector 对读写操作均加锁同步,可以保证容器的读写强⼀致性,CopyOnWriteArrayList由于其实现策略的原因,写和读分别作⽤于不容容器上,在写的过程中,读是不会发⽣阻塞的,未切换索引置新容器时,是读不到刚写⼊的数据的。
总结:读旧的,写新的,写完指针指向新的,旧的没有被引用就会被GC

以上是关于并发编程基础的主要内容,如果未能解决你的问题,请参考以下文章

golang代码片段(摘抄)

《java并发编程实战》

Java并发编程实战 04死锁了怎么办?

Java并发编程实战 04死锁了怎么办?

[Go] 通过 17 个简短代码片段,切底弄懂 channel 基础

Java编程思想之二十 并发