Java线程安全和锁原理

线程安全:当多个线程访问一个对象时,如果不需要考虑运行时环境下的调度和交替执行,也不需要额外的同步,以及在调用方不需要任何的协调操作,调用这个对象的行为都能获得正确的结果,则称这个对象是线程安全的。

1. Java中线程安全的特性和实现

1.1 不可变

不可变对象一定是线程安全的,一旦构建出来之后,其外部的可见状态永远不会改变,属于最简单的实现。除了String,包括java.lang.Number的Long和double,BigInteger等;⚠️但AtomicInteger和AtomicLong是并非不可变的对象。(内部自旋锁实现自增,细节可以再深入了解)

1.2 绝对线程安全

Java中属于线程安全的类,大多数都不是绝对的线程安全。如果要实现绝对的线程安全即满足“不管运行时环境如何,调用者都不需要任何额外的同步措施”,可能付出很大的代价甚至不切实际。如Java Vector对所有add() get() set()方法都加了synchronized同步块,但某些情况仍然需要自己处理同步。

1.3 相对线程安全

相对的线程安全指保证对一个对象单独的操作是线程安全的,不需要在调用时附加额外的保障措施,而在一些特定顺序的连续调用中,需要补充额外的同步手段保证线程安全,即调用的正确性。Java中包括Vector,HashTable,Collections里synchronizedCollection方法包装的集合。

1.4 线程兼容

指对象本身不是线程安全的,但是可以通过在调用端使用同步手段来保证对象在并发环境中可以安全地使用。举例如Vector,HashTable对应的ArrayList和HashMap等。

1.5 线程对立

指无论调用端是否采取了同步措施,都无法保证在多线程环境中并发的使用代码。举例如suspend和resume方法,如果中断的正好是要执行resume的线程,造成死锁,目前已经被JDK声明废弃。其他举例如:System.setIn() System.setOut() System.runFinalizersOnExit()

2. 线程安全的实现方法

2.1 互斥同步

同步是指多个线程并发的访问共享数据时,保证共享数据同一时刻只能被一个线程使用。而互斥可以通过临界区,互斥量,信号量来实现。Java中实现基本的互斥同步是synchronized关键字。会在编译后在同步块的前后分别加monitorenter和monitorexit字节码,这俩字节码都需要一个reference的引用参数来指明锁定和解锁的对象,如果没有指定对象参数,就会根据修饰的类型(实例方法和类方法),选取相应的实例或者Class来作为锁对象。
⚠️在monitorenter时候,会先尝试获取对象的锁,如果对象没被锁定,或者当前的线程已经拥有了这个对象的锁,则将锁计数器+1,相应在monitorexit的时候计数器-1,如果获取锁失败,当前线程会阻塞等待,知道锁被另一个线程释放。
⚠️对于同一个线程,synchronized是可重入的,不会将自己锁死。因为Java的线程是映射到操作系统原生线程的,所以如果阻塞或者唤醒线程,都需要从用户态转入内核态,由操作系统完成,需要耗费很多的处理器时间,所以synchronized是一个成本较高的操作。但是虚拟机本身会有一些优化,如在通知操作系统阻塞之前加入一个自旋等待的过程,避免频繁切换到内核态。
除了synchronized还可以使用ReentrantLock实现同步。可重入锁属于在API层面同步哦lock unlock和配合try catch 来完成,synchronized属于在原生语法层面的互斥锁。可重入锁相比有以下特性:

  • 等待可中断:如果持有锁的线程长期不释放,正在等待的线程可以选择放弃等待,去做其他的事情,对处理执行时间很长的同步块很有用。
  • 公平锁 :指多个线程在等待同一个锁时,可以根据申请锁的时间顺序来依次获得锁。默认下可重入锁是非公平的,可以通过带参数的构造函数使用公平锁。
  • 可以绑定多个条件:指一个可重入锁对象可以同时绑定多个Condition对象,可以通过newCondition添加,而synchronized如果多于一个条件,需要额外地添加一个锁。notify()方法进行通知时,被通知的线程时Java虚拟机随机选择的,但是ReentrantLock结合Condition可以实现有选择性地通知。
    ⚠️JDK1.6至于synchronized和ReentrantLock的性能相差不多,优先使用synchronized,但在需要绑定多个条件,公平锁等条件下,可以多使用可重入锁。

2.2 非阻塞同步

互斥同步最主要的问题就是线程阻塞和唤醒带来的性能问题,也称为阻塞同步,属于悲观的并发策略,认为不加同步手段肯定会出问题。随着硬件指令集的发展,有了另一种基于冲突检测的乐观并发策略,详细来说就是如果没有其他线程争用共享数据,擦坐就成功了;如果有其他线程对共享数据有争用,即表明出现了冲突,再采取补偿措施,如不断重试,这样的乐观策略实现很多不需要将线程挂起。想起包括如下硬件指令:

  • 测试并设置 Test-and-Set
  • 获取并添加 Fetch-and-Increment
  • 交换
  • 比较并交换 Compare-and-Swap CAS
  • 加载链接,条件存储 Load-Linked/Store-Conditional LL/SC

详细介绍CAS:属于在处理器层面的直接指令,虚拟机会做特殊处理,编译后是一条平台相关的处理器CAS指令。Java中CAS包括三个操作数,内存位置V,旧的预期值A,新值B。当CAS执行时,当且仅当V符合旧的预期值A时候,处理器用新的B去更新V的值,否则不执行更新,是一个原子操作。
但是有个CAS有个漏洞是:如果V初次读取的时候是A值,但是中间被从B又改到了A,但CAS仍然认为没有被改变过,即典型的ABA问题。Java.U.C包添加了原子引用类,AtomicStampedReference,通过控制版本来保证CAS的正确性,不过打不行情况下ABA不会影响程序并发的正确性。

2.3 无同步方案

同步只是实现线程安全的手段,保证共享数据争用时的正确性,如果一个方法本身就不涉及共享数据,也就不需要任何的同步措施来保证正确性。

  1. 可重入代码
    代码不依赖存储在堆上的数据和公用的系统资源,用到的状态量都由参数传入,不调用非可重入的方法。他的返回结果是可以预测的,只要输入了相同的数据,都能返回相同的结果,即满足了可重入的要求,也是线程安全的。

  2. 线程本地存储
    如果一段代码中需要的数据必须与其他代码共享,则需要确定这些共享数据的代码是否能保证在同一个线程中执行,如果能保证,则说明共享数据的可见范围在一个线程之内,也就无须同步也可以保证不出现数据争用的问题。举例如生产者-消费者的模型,经消费过程在一个线程中消费完成;以及Web服务器一个请求对应一个服务器线程;Java中可以通过TheadLocal 来实现线程本地存储,每个Thread中都有一个ThreadLocalMap的对象,其中存储threadLocalHashCode作为键,一本地线程变量为值的K-V对,每个Thread Local对象有自己的threadLocalHashCode,可以痛殴这个值在线程k-v中找到对应的本地线程变量。

3. 锁优化

  1. 自旋锁
    主要通过一个忙循环来实现,避免了线程切换的开销,但是需要占用CPU时间。自旋等待的时间需要限制,如果超过次数,就应当使用传统的方式去挂起线程,默认是10次,可以通过-XX:PreBlockPin设置。1.6之后引入自适应的自旋锁,次数会根据之前一次在同一个锁上面的自选时间及锁拥有者的状态决定,极端的情况,如果很少自旋成功,可能之后会省略掉自旋过程,避免浪费处理器资源。
  2. 锁消除
    在虚拟机运行时,代码中要求同步,但是检测到不可能出现争用共享数据的情况时,基于“逃逸分析”的支持判断,在一段代码中,堆上的数据不会逃逸出去被别的线程访问到,可以认为就是线程私有的,没有必要做同步加锁。
    ⚠️可能人工没有加入同步,但是编译器优化会出现增加同步的情况,典型例子是String ++ 会转换成StringBuilder.append(),但是经过判断StringBuilder对象作用域都在方法内部,其他线程没法使用,所以可以做锁消除。以下举例代码
//code 1
public String concatString(String s1, String s2, String s3){
      return s1 + s2 + s3;
}
//code 2
public String concatString(String s1, String s2, String s3){
      StringBuilder sb = new StringBuilder();
      sb.append(s1);
      sb.append(s2);
      sb.append(s3);
    return sb.toString();
}

其他作为了解

  1. 锁粗化
  2. 轻量级锁
  3. 偏向锁

你可能感兴趣的:(Java线程安全和锁原理)