Java并发编程-共享模型管程(一)

194 阅读4分钟

1.共享带来的问题

多线程下分时系统中,CPU会轮流给每个线程使用,当时间片用完时,或者当线程处于sleep,IO阻塞,wait状态时(统称阻塞态),会把CPU资源分给其他线程去使用。当多个线程访问共享资源的时候,因为这个原因就可能产生一些错误。

例如t1线程对数据进行特定操作,还未操作共享资源时,CPU已经给t2线程使用,t2线程写入数据,当t1重新拿到处理机继续执行操作,可是此时的数据结果已经并非t1或者t2所希望得到的。

1.1Java的体现

public class Test01 {
    //定义共享资源
    static int counter = 0;

    public static void main(String[] args) throws InterruptedException {
        //对共享资源进行自增操作
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter++;
            }
        }, "t1");
        //对共享资源执行自减操作
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter--;
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        //希望得到结果为0
        System.out.println(counter);
    }
}

1.2问题分析

以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析

对于i++操作(i为静态变量),实际上会产生如下JVM字节码指令

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

当一个线程对内存中的i进行操作时,可能指令还未执行完,另一个线程也对i进行操作,直观来说就是不同线程间的指令交替执行了最终造成结果错误

1.3临界区 Critical Section

多个线程读取共享资源没有问题

多个线程对共享资源的读写操作时指令发生交错,就会出现问题

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

//共享资源
static int counter = 0;

static void increment()

// 临界区
{
counter++;
}

static void decrement()

// 临界区
{
counter--;
}

1.4竞态条件 Race Condition

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

2.synchronized 解决方案

2.1应用之互斥

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

  1. 阻塞式的解决方案:synchronized,Lock
  2. 非阻塞式的解决方案:原子变量

synchronized,来解决上述问题,即俗称的【对象锁】,它采用互斥的方式让同一 时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。

java 中互斥和同步都可以采用 synchronized 关键字来完成,但还是有区别的:

  1. 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码

  2. 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点

2.2synchronized

语法

synchronized(对象) // 线程1, 线程2(blocked)
{
临界区
}

使用(解决上面提到的结果错误问题)

public class Test01 {
    static int counter = 0;
    static final Object room = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (room) {
                    counter++;
                }
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (room) {
                    counter--;
                }
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter);
    }

2.3理解

可以把synchronized (room)看作是一个操作间,只有线程进入到这个操作间之后,才可以执行临界区的代码,其他线程都无权干扰打断。

哪怕是使用线程cpu时间片用完(发生上下文切换),其他线程如果想要进入操作间就会变成阻塞状态,只要没有释放其他线程仍然没有使用权,在这个房间被释放前都不能进入操作间进行临界区的操作。

当拥有锁的线程执行完毕就会释放锁,放开操作间使用资格,并且唤醒其他的阻塞线程,并且该线程就成为锁的拥有者。

synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。

  • 问题

如果把 synchronized(obj) 放在 for 循环的外面,如何理解?-- 仍然生效,增加了临界区代码,原子性

如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?-- 锁对象不同,类比于有两个操作间,对同一资源加锁,锁对象需要一致

如果 t1 synchronized(obj) 而 t2 没有加会怎么样?如何理解?-- 加锁不完全,对于代码的原子性没有保障

2.4面向对象改进

把需要保护的共享变量放入一个类

class Room {
    int value = 0;

    public void increment() {
        synchronized (this) {
            value++;
        }
    }

    public void decrement() {
        synchronized (this) {
            value--;
        }
    }

    public int get() {
        synchronized (this) {
            return value;
        }
    }
}

public class Test01 {
    public void main(String[] args) throws InterruptedException {
        Room room = new Room();
        Thread t1 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                room.increment();
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                room.decrement();
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(room.get());
    }
}

3.方法上的 synchronized

这样锁加在这个对象上,锁的是调用这个方法的对象

class Test{
    public synchronized void test() {

    }
}
等价于
class Test{
    public void test() {
        synchronized(this) {

        }
    }
}

static锁的是整个类对象

class Test{
    public synchronized static void test() {
    }
}
等价于
class Test{
    public static void test() {
        synchronized(Test.class) {

        }
    }
}

不加 synchronized 的方法

不加 synchronzied 的方法就好比不遵守规则的人,不去老实排队(好比翻窗户进去的)

4.变量的线程安全分析

4.1成员变量和静态变量是否线程安全?

  1. 如果它们没有被共享则线程安全

  2. 如果他们被共享,根据它们的状态能否被改变,分两种情况

  • 如果只有读操作,线程安全
  • 如果既有读又有写操作,则这段代码是临界区,需要考虑线程安全

4.2局部变量是否线程安全?

  1. 局部变量是线程安全的

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

  • 如果该对象没有逃离方法的作用访问,它是线程安全的
  • 如果该对象逃离方法的作用范围,需要考虑线程安全

4.3局部变量线程安全分析

public static void test1() {
    int i = 10;
    i++; }

每个线程调用 test1() 方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,因此不存在共享

成员变量例子

import java.util.ArrayList;

public class Test02 {
    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();
        }
    }
}

class ThreadUnsafe {
    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);
    }
}

其中一种情况是,如果线程2 还未 add,线程1 remove 就会报错:

Exception in thread "Thread1" java.lang.IndexOutOfBoundsException: Index: 0, Size: 0
at java.util.ArrayList.rangeCheck(ArrayList.java:657)
at java.util.ArrayList.remove(ArrayList.java:496)
at cn.itcast.n6.ThreadUnsafe.method3(TestThreadSafe.java:35)
at cn.itcast.n6.ThreadUnsafe.method1(TestThreadSafe.java:26)
at cn.itcast.n6.TestThreadSafe.lambda$main$0(TestThreadSafe.java:14)
at java.lang.Thread.run(Thread.java:748)

分析

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

2.method3 与 method2 分析相同

解决:

class ThreadSafe {
    public final void method1(int loopNumber) {
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < loopNumber; i++) {
            method2(list);
            method3(list);
        }
    }
    //将上述堆中建立的list作为参数传递到method2
    private void method2(ArrayList<String> list) {
        list.add("1");
    }
    //同理
    private void method3(ArrayList<String> list) {
        list.remove(0);
    }
}

当不同线程去调用method1方法时(之后执行method2和method3),都会在堆中创建各自的list对象,之后对自身的list对象进行操作就不存在共享问题

分析:

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

而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象

method3 的参数分析与 method2 相同

关键:局部变量的引用没有暴露给外部的时候是安全的

当局部变量暴露给外部

方法访问修饰符带来的思考,如果把 method2 和 method3 的方法修改为 public 会不会代理线程安全问题

class ThreadSafe {
    public final void method1(int loopNumber) {
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < loopNumber; i++) {
            method2(list);
            method3(list);
        }
    }
    public void method2(ArrayList<String> list) {
        list.add("1");
    }
    public void method3(ArrayList<String> list) {
        list.remove(0);
    }
}

方法2,3有可能被其他方法调用

情况1:有其它线程调用 method2 和 method3

考虑:例如线程1调用method1,线程2直接调用method2

不会存在线程安全问题,1调用method1使用的是自身在堆中创建的list对象,2调用的method2传入的list不是1调用堆中的list

情况2:在 情况1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法,即

class ThreadSafe {
    //增加final防止子类重写,覆盖方法
    public final void method1(int loopNumber) {
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < loopNumber; i++) {
            method2(list);
            method3(list);
        }
    }

    public void method2(ArrayList<String> list) {
        list.add("1");
    }

    public void method3(ArrayList<String> list) {
        list.remove(0);
    }
}

//子类
class ThreadSafeSubClass extends ThreadSafe {
    @Override
    public void method3(ArrayList<String> list) {
    //若method3被调用传入list对象
    //内部就有一个新线程与之前线程共享list
        new Thread(() -> {
            list.remove(0);
        }).start();
    }
}

这里可以理解为主线程创建list对象使用,调用方法mehtod1传入了list对象,当调用到method3时会开启一个新线程同时把该list对象传入使用,就形成了共享

从这个例子可以看出 private 或 final 提供【安全】的意义所在,如此子类中的方法就无法覆盖,体会开闭原则中的【闭】

5.线程安全类

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

这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为

  • 它们的每个方法是原子的
  • 但注意它们多个方法的组合不是原子的,见后面分析
public void main(String[] args) {
    //保障线程安全的
    Hashtable table = new Hashtable();
    
    new Thread(() -> {
        table.put("key", "value1");
    }).start();
    new Thread(() -> {
        table.put("key", "value2");
    }).start();
}

hashtable put.png 不被线程上下文切换所侵扰,保证方法中代码的原子性

  • 它们的每个方法是原子的
  • 但注意它们多个方法的组合不是原子的,见后面分析

5.1线程安全类方法的组合

多个方法组合使用不一定线程安全

Hashtable table = new Hashtable();

// 线程1,线程2

if( table.get("key") == null) {
    table.put("key", value);
}

说明:线程1执行到table.get("key") == null判断完就发生了上下文切换,未进行后续的put操作,线程2执行 table.get("key") == null,并且完成了put操作,此时上下文切换到线程一,线程一继续执行put方法,此时就发生了错误。

5.2不可变类线程安全性

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

思考:String 有 replace,substring 等方法可以改变值,那么这些方法又是如何保证线程安全的呢?

string.png String.substring本质上是创建了一个新的字符串对象对原字符串做一个复制

根本没有改变值(改变对象的属性)