Java并发成神系列(3)—原子性问题及解决方案(管程)1

1.多线程出现问题的原因

1.1 原因分析

多线程出现共享问题的根本原因就是由于线程上下文切换,导致线程里的指令没有执行完就切换其它线程来操作了。(这里多线程访问的是共享资源)

典型例子:

 static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 1;i<5000;i++){
                count++;
            }
        });
        Thread t2 =new Thread(()->{
            for (int i = 1;i<5000;i++){
                count--;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.debug("count的值是{}",count);
    }

输出结果count的值不是0,有可能是正数也有可能是负数。

问题分析:

这需要从字节码的层面来理解,i++ 这个操作在java代码层面看是一个指令,而在字节码层面看,它是多个指令,不是一个原子操作。

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

我们看到count++count--这两个操作实际上是需要4个指令完成的。在Java内存模型下,完成静态变量的自增和自减需要在主内存和工作内存之间进行数据交换。因此,这也就是问题所在,当一个线程的count++中的4个指令还没有运行完,就发生了线程上下文切换,就要出问题了!

 上述代码出现负数的情况:

 上述代码出现正数的情况:

1.2 问题的专业描述

临界区(Critical Section)

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

2)问题的原因在于多个线程访问的是共享资源(多个线程都可以访问的,比如Java堆、方法区)

  • 多个线程同时共享资源也是没有问题的

  • 而多个线程同时对共享资源进行读写操作就会出现问题了(读写操作发生指令交错会出问题)

3)临界区: 一段代码存在对共享资源的多线程读写操作时,称这段代码为临界区

例如:

static int count = 0;
static void increment() {
  //临界区
  count ++;
}
static void decrement() {
  //临界区
  count --;
}
竞态条件(Race Condition)

竞态条件:

多个线程在临界区执行,由于代码的执行序列不同而导致结果无法预测

1.3 解决方案

解决目的就是避免竞态条件的发生,有两大类解决方法:

1)阻塞式的解决方案:synchronized, Lock

2)非阻塞式的解决方案:原子变量(CAS)

2.synchronized 解决方案

1)解决思路:

synchronized (也叫对象锁)的解决办法是采用互斥的方法,让同一时刻至多只有一个线程能持有【对象锁】,其它线程想获得【对象锁】就会阻塞住,这样就可以保证同一时刻只有拥有锁的线程可以执行临界区的代码。

原理: synchronized 利用【对象锁】保证了临界区代码的原子性,临界区的代码在外界看来是不可分割的,不会被线程切换打断。

2)synchronized语法:

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

上述例子的解决:加上synchronized之后,结果正确了!

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();
     log.debug("{}",counter);

注意:

1)线程t1拿到锁之后,并不是一直执行下去的,当t1的CPU时间片用完了,它会暂停下来,但并不会释放锁;这时t2是拿不到锁的,仍然会阻塞着,当t1重新运行时,又可以继续执行下去,执行结束才会释放锁。

2) synchronized 必须锁同一个对象才有效;当两个 synchronized锁的不是一个对象,就会失效

3) synchronized 时必须在临界区都加锁,只有一个加锁也会失效

3) synchronized加在方法上

① synchronized加到成员方法上

		class Test{
        public synchronized void test() {

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

            }
        }
    }

② synchronized加到静态方法上

   class Test{
        public synchronized static void test() {
        }
    }
   // 等价于,锁的是类的class对象
    class Test{
        public static void test() {
            synchronized(Test.class) {

            }
        }
    }

注意: synchronized只能锁对象!

3. 变量的线程安全分析

3.1 成员变量和静态变量的线程安全分析

两种情况:共享和非共享

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

2)如果它们被共享了,又分为两种情况:

①如果只有读操作,则仍是线程安全的

②如果有读写操作,则这段代码就是临界区,存在线程安全问题。

成员变量线程不安全的例子:

不安全原因分析:

@Slf4j(topic = "c.Code_18_Test")
public class Code_18_Test {

    public static void main(String[] args) {
        UnsafeTest unsafeTest = new UnsafeTest();
        for(int i = 0; i < 10; i++) {
            new Thread(() -> {
                unsafeTest.method1();
            }, "t" + i).start();
        }
    }

}

class UnsafeTest {

    List<Integer> list = new ArrayList<>();

    public void method1() {
        for (int i = 0; i < 200; i++) {
            method2();
            method3();
        }
    }

    public void method2() {
        list.add(1);
    }

    public void method3() {
        list.remove(0);
    }

}

上述list是一个成员变量,而且它是被线程共享的,method2和method3都是引用的同一个成员变量,所以会出现线程安全问题。首先,list.add操作是有三个步骤,第一步是获取添加元素的下标index,第二步是对指定的index位置添加元素,第三步是将index往后移。因此,当t1线程从list拿到index = 0时,t1线程的时间片用完了,t2线程获取到时间片开始运行,也从list拿到index = 0,然后添加元素到index = 0的位置,这时t2线程时间片用完了,当t1线程也会往index = 0的位置添加元素,此时index = 0位置已经有元素了,这时就会出现报错。

 

3.2 局部变量线程安全分析

1)局部变量是线程安全的。

2)但是局部变量引用的对象不一定

①如果该对象没有逃离方法的作用范围,则它是线程安全的

②如果该对象逃离方法的作用范围,则需要考虑线程安全

局部变量线程安全情况:

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

每个线程调用 test1() 方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,因此不存在共享。(局部变量存在于java虚拟机栈中,该数据区是线程私有的)

 

上述成员变量线程安全问题的解决方案:

就是将list 改为局部变量,这样就不存在共享问题了,每个线程都会创建一个新的list 对象,method2和method3引用的也都是从method1 中传过来的list对象。

class safeTest{
    public void method1(){
        ArrayList<String> arrayList = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
        method2(arrayList);
        method3(arrayList);}
    }
    private void method2(ArrayList arrayList) {
        arrayList.add("1");
    }
    private void method3(ArrayList arrayList) {
        arrayList.remove(0);
    }
}

访问修饰符引发的一些问题:

把上述例子中method2method3方法的修饰符改为public,这时

1)如果有其它线程调用method2method3,是不存在线程安全问题的,因为不同线程肯定不是同一个list对象

2)如果添加了一个子类,并且覆盖了method3或者method2方法,就可能会出现线程安全问题:

如下这时,新的调用method3时会有一个新的线程操作list对象,相当于list对像又被共享了!!(也就是把局部变量引用的对象暴露给了外部)

class safeTest{
    public void method1(){
        ArrayList<String> arrayList = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
        method2(arrayList);
        method3(arrayList);}
    }
    public void method2(ArrayList arrayList) {
        arrayList.add("1");
    }
    public void method3(ArrayList arrayList) {
        arrayList.remove(0);
    }
}

class ThreadSafeSubClass extends ThreadSafe{
 	@Override
 	public void method3(ArrayList<String> list) {
 		new Thread(() -> {
 			list.remove(0);
 		}).start();
	 }
}

因此可以看到private或者final提供安全的意义,防止子类的干扰,也就是开闭原则中的闭。

private或者final可以避免子类覆盖!

3.3 常见的线程安全类

  1. String

  2. Integer

  3. StringBuffer

  4. Random

  5. Vector :list的线程安全的实现

  6. Hashtable:map的线程安全的实现

  7. 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);
}

 

不可变类的线程安全:

StringInteger类都是不可变的类,因为其类内部状态(内部属性)是不可改变的,因此它们的方法都是线程安全的,也就是只可以有读操作。

注意:Stringreplacesubstring 等方法返回的已经是一个新创建的对象了!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值