多线程-浅析线程安全

多线程-共享模型之管程

本文章是根据黑马JUC课程编写,记录的笔记

1 共享带来的问题

在平常开发中,很多时候都会遇到共享数据的问题,比如售票,库存。那么如何就会引出一个疑问,如何保证数据的安全性呢(就是数据共享的问题)!

  • 下面一个小案例说明。
  • 创建两个线程,对一个静态变量进行自增或者自减的操作,模仿买票售票,等等问题。
  • 正常来说,他们最终的结果会是0
    static Integer num = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> { 
            for (int i = 0; i < 5000; i++) {
                num++;
            }
        }, "t1");

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

        t1.start();
        t2.start();
        t1.join();// 使其他线程等待t1执行完成
        t2.join();// 使其他线程等待t2执行完成
        log.info("num:{}", num);
    }

执行多次,会发现,每次结果不相同,正常情况下,他们的执行结果会是0。

  • 为什么结果不是0

    • 首先我们得了num++ 会产生什么字节码

      num++
      getstatic i // 获取静态变量i的值
      iconst_1 // 准备常量1
      iadd // 自增
      putstatic i // 将修改后的值存入静态变量i
      
      num--
      getstatic i // 获取静态变量i的值
      iconst_1 // 准备常量1
      isub // 自减
      putstatic i // 将修改后的值存入静态变量i
      
    • 简单来说,就是当我们t1线程刚对num进行自增操作时,此时线程进行了程序上下文切换(简单说就是cpu给他的时间用完了,进入Rnnable 可运行状态),然而下一次上下文切换,并没有给到t1,给到t2。t1还未对常量池中的num赋值,也就是说,t1这次的自增没有成功,假如t2成功,num的值就发生了错乱。这也就是num不为0的原因

image-20220816143612767

2 临界区 Critical Section

  • 一个程序运行多个线程本身是没有问题的
  • 问题出在多个线程访问共享资源
    • 多个线程读共享资源其实也没有问题
    • 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
  • 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区

例如,下面代码中的临界区:

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

static void decrement() 
// 临界区
{ 
 counter--;
}

竞态条件 Race Condition

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


3 解决共享带来的问题

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

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

我们采用synchronized对象锁的方式来解决,其他解决方案后续会有。

1 什么是synchronized

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

  • 注意

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

    • 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
    • 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点

2 语法

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

3 解决

把上面案例的临界区用 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 (lock) {
                    num++;
                }
            }
        }, "t1");

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

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.info("num:{}", num);
}

4 思考

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

  • 请看下图红框位置,原本是我们线程2拥有cpu的使用权,在进行自增或者自减时,会产生四条字节码,对应下面四步操作,当执行到第三步,发生了上下文切换,因为我们使用synchronized ,使用并不会让1线程获取到锁(会被阻塞),然后继续上下文切换,直到切换到拥有锁的线程,也就是我们线程2.

image-20220817085352786

几个小提问

  • 如果把 synchronized(obj) 放在 for 循环的外面,如何理解?-- 原子性
    • **答:**上次我们for循环是循环5000次,因为synchronized会保证临界区的原子性,也就是说会一次执行完5000次,然后其他线程才能执行。
  • 如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?-- 锁对象
    • **答:**都知道synchronized 是锁对象,他们俩是不同对象,所有还是会存在线程安全。
  • 如果 t1 synchronized(obj) 而 t2 没有加会怎么样?如何理解?-- 锁对象
    • **答:**这种情况还是会存在线程安全,因为t2线程根本不需要去获取锁,所有并没有没阻塞。

4 进行改进

还是上面那个问题,平常开发中,我们更多的是对 对象的某个属性进行操作,所有我们采用面向对象的方式,解决这个问题。

@Slf4j
public class C1_线程安全面向对象 {
    public static void main(String[] args) throws InterruptedException {
        Room room = new Room();
        // 创建两个线程
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                room.add();
            }
        }, "t1");

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

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.info("num:{}", room.get());
    }
}

// 被操作的对象
class Room{
    int num = 0;

    // 自加
    public void add(){
        synchronized (this){
            num++;
        }
    }
    // 自减
    public void sub(){
        synchronized (this){
            num--;
        }
    }
	// 获取
    public int get(){
        synchronized (this){
            return num;
        }
    }
}

可以执行测试,效果是一样的。

5 变量的线程安全分析

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

  • 如果它们没有共享,则线程安全
  • 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
    • 如果只有读操作,则线程安全
    • 如果有读写操作,则这段代码是临界区,需要考虑线程安全

局部变量是否线程安全?

局部变量是线程安全的

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

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

1 成员变量

先看一个成员变量的例子

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);
	}
    
    // 线程的数量
    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();
 		}
	} 
}

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

  • 因为这两个线程是对同一个对象,进行修改或者赋值的操作。(一般都会采用局部变量)

image-20220817092351500

示例图

image-20220817092559793


2 局部变量

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

采用局部变量,就不会出现上面问题了。可以看到,每个线程都只是对自己的局部变量进行操作

  • list 是局部变量,每个线程调用时会创建其不同实例,没有共享
  • 而 method2 的参数是从 method1 中传递过来的,
  • 与 method1 中引用同一个对象 method3 的参数分析与 method2 相同

示例图

image-20220817092732828

方法访问修饰符带来的思考,

如果把 method2 和 method3 的方法修改为 public 会不会代理线程安全问题? 情况1:有其它线程调用 method2 和 method3 情况2:在 情况1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法,即

class ThreadSafe {
 	public final void method1(int loopNumber) {
 		ArrayList<String> list = new ArrayList<>();
 		for (int i = 0; i < loopNumber; i++) {
            // 形成新的临界区
 			method2(list);
			method3(list);
		 }
	 }
 	private void method2(ArrayList<String> list) {
 		list.add("1");
     }
 	private void method3(ArrayList<String> list) {
 		list.remove(0);
 	}
}
class ThreadSafeSubClass extends ThreadSafe{
 @Override
 public void method3(ArrayList<String> list) {
 	new Thread(() -> {
 	list.remove(0);
 			}).start();
	 }
}

执行,还是会存在线程安全问题,在for循环中,又会形成新的临界区,因为子类重写了method3,创建了线程,然而我们不能限制子类的行为。

  • 从这个例子可以看出 private 或 final 提供【安全】的意义所在,请体会开闭原则中的【闭】

闭合原则:String

6 常见线程安全类

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

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

  • 它们的每个方法是原子的
  • 但注意它们多个方法的组合不是原子的,见后面分析
Hashtable table = new Hashtable();
new Thread(()->{
 	table.put("key", "value1");
}).start();
new Thread(()->{
 	table.put("key", "value2");
}).start();

6.1 分析下面代码是否线程安全?

Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
 	table.put("key", value);
}

别灰心,你肯定打错了,我也打错了。给个提示,这些线程安全类的方法,单个是线程安全的,那么多个组合起立还是不是呢。

  • 有没有可能发送这种情况,线程1执行完get,发生了上下文切换,然后线程2也执行完get,线程2有执行了put操作,再然后线程1又进行了put

image-20220817094112776

6.2 不可变类线程安全性

String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的 有同学或许有疑问,String 有 replace,substring 等方法【可以】改变值啊,那么这些方法又是如何保证线程安全的呢?

  • 本质上每次String执行 replace,substring,都会创建一个新的对象,并没有改变。所以不存在线程安全。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值