Java变量共享引发的惨案,不得已走进的悲观锁

Java变量共享引发的惨案,不得已走进的悲观锁

相关:

精湛细腻版-Java多线程与并发编程
硬核学习Synchronized原理(底层结构、锁优化过程)

不加锁带来的问题

主要是共享变量带来的问题:

两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

package c2;

public class TestJoin {

    static int count = 0 ; //共享变量
    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count ++ ;
            }
        },"t1") ;

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count -- ;
            }
        },"t2") ;

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(count);
    }
}

问题分析

以上的结果可能是正数、负数、零。为什么呢?

因为 Java 中对静态变量的自增,自减并不是原子操作

要彻底理解,必须从字节码来进行分析

例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:

getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i

而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:

Java变量共享引发的惨案,不得已走进的悲观锁_第1张图片

如果是单线程以上 8 行(算上i–)代码是顺序执行(不会交错)没有问题

但多线程下这 8 行代码可能交错运行,如下

Java变量共享引发的惨案,不得已走进的悲观锁_第2张图片

本质上,这种问题是指令交错运行导致的(假设单核多线程)

在之后,我会讲之抽象到从JMM内存模型来看待这个问题

那么,多线程如何解决共享变量的正确性的呢?

临界区

一个程序运行多个线程本身是没有问题的

问题出在多个线程访问共享资源

  • 多个线程读共享资源其实也没有问题
  • 在多个线程对共享资源读写操作时发生指令交错,就会出现问题

一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区

static int counter = 0;
static void increment() 
// 临界区
{ 
 counter++; }
static void decrement() 
// 临界区
{ 
 counter--; }

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

解决方案:为了避免临界区的竞态条件发生,有多种手段可以达到目的

  • 基于乐观锁思想的阻塞式的解决方案:synchronized,Lock

  • 基于悲观锁思想的非阻塞式的解决方案:原子变量

Java - synchronized 解决方案

硬核学习Synchronized原理(底层结构、锁优化过程)

变量的线程安全分析

类成员变量

  • 如果没有被共享,则线程安全
  • 如果被共享了
    • 如果只有读操作,则线程安全
    • 如果有读写操作,则这段代码是临界区,需要考虑线程安全

局部变量

  • 局部变量是线程安全的,因为JVM会为每个线程建立栈帧,局部变量存在栈帧中,是线程独享的,不会被共享

  • 但局部变量引用的对象则未必

    • 如果该对象没有逃离方法的作用范围,它是线程安全的(逃逸分析),或引用的对象在方法内,为局部变量

    • 如果该对象逃离方法的作用范围,或引用的对象为类成员变量,需要考虑线程安全

重点说下局部变量的引用问题

观察下面代码,分析

package c2;

import java.util.ArrayList;

class ThreadUnsafe {

    static final int THREAD_NUMBER = 2;
    static final int LOOP_NUMBER = 200;  //循环次数

    public static void main(String[] args) {
        ThreadUnsafe test = new ThreadUnsafe();
        for (int i = 0; i < THREAD_NUMBER; i++) { //创建两个线程
            new Thread(() -> {
                test.method1(LOOP_NUMBER);
            }, "Thread" + i).start();
        }
    }

    /*类成员变量*/
    ArrayList<String> list = new ArrayList<>();

    public void method1(int loopNumber) {
        for (int i = 0; i < loopNumber; i++) {
            // { 临界区, 会产生竞态条件
            method2();
            method3();
            // }
        }
    }
    private void method2() {
        list.add("1");
    }
    private void method3() {
        list.remove(0);
    }

}

分析:

无论哪个线程中的 method2、method3 引用的都是同一个对象, list 成员变量

Java变量共享引发的惨案,不得已走进的悲观锁_第3张图片

将list改为局部变量,则线程安全

   public void method1(int loopNumber) {
        /*类成员变量*/
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < loopNumber; i++) {
            // { 临界区, 会产生竞态条件
            method2(list);
            method3(list);
            // }
        }
    }
    private void method2(List list) {
        list.add("1");
    }
    private void method3(List list) {
        list.remove(0);
    }

  • list 是局部变量,每个线程调用时会创建其不同实例,没有共享

  • 而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象,在同一个线程内的方法的同步调用的

  • method3 的参数分析与 method2 相同

Java变量共享引发的惨案,不得已走进的悲观锁_第4张图片

这样就安全了吗?未必,如果method2或method3的方法修饰符是public,同时又有一个子类继承了这两个方法的一个,并且在子类的重写方法中又创建了一个线程,那么又造成了多个线程访问一个变量的情况,即使是局部变量,它也变成了共享的了,这是逃逸分析中的对象参数逃逸,简单理解为list逃逸了原来方法的作用范围,跑到了子类中的方法

可以看出 private 或 fifinal 提供【安全】的意义所在,请体会设计模式中开闭原则中的【闭】

常见的线程安全类

  • String
  • Integer
  • StringBuffffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent 包下的类

这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。如

Hashtable table = new Hashtable();
new Thread(()->{
 table.put("key", "value1");
}).start();
new Thread(()->{
 table.put("key", "value2");
}).start();
  • 线程安全类的的每个方法是原子的

  • 注意它们多个方法的组合不是原子的

    Hashtable table = new Hashtable();
    // 线程1,线程2
    //   --- 整段代码非线程安全
    if( table.get("key") == null) {
     table.put("key", value);
    }  //
    

String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的

你或许有疑问,String 有 replace,substring 等方法可以改变值啊,那么这些方法又是如何保证线程安全的呢?事实上,这些方法并不是真的改变值,而是通过建立新的串或值来达到改变的效果

你可能感兴趣的:(Java并发编程)