线程安全问题分析

java与线程

线程的实现

主流的操作系统都提供了线程实现,java提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个java.lang.Thread类的实例就代表了一个线程。Thread类的所有关键方法都被声明为native

线程实现的方式

  1. 使用内核线程实现 (1:1)
    1. 内核线程(KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换。内核通过调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程都可以看做是内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫多线程内核
      程序一般不会直接区使用内核线程,而是使用内核线程的一种高级接口–轻量级进程,轻量级进程就是我们通常意义上讲的线程。由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。
  1. 使用户线程实现(1:N)
    1. 广义上来讲,一个线程只要不是内核线程,那就可以认为是用户线程,从这个定义来看轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,因此效率会受到限制。
      狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到线程存在的实现。用户线程的建立,同步,销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持更大规模的线程数量,部分高性能数据库中的多线程就是由用户线程实现的,这种进程与用户线程之间1:N的关系称为一对多的线程模型
  1. 使用户线程加轻量级进程混合实现 (M:N)
    1. 将内核线程与用户线程一起使用的实现方式,在这种混合实现下,即存在用户线程,也存在轻量级进程,用户线程运行方式不变,操作系统提供的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了进程被阻塞的风险,,用户线程和轻量级进程的数量比是不确定的,是M:N的关系,这就是多对多的线程模型 ,许多Unix操作系统如Solaris采用这种模型。
4、 java线程的实现
对应sun JDK来说,windows版本与Linux版本都是使用一对一的线程模型来实现的,一条java线程就映射到一条轻量级进程之中,因为windows和Linux系统提供的线程模型就是一对一的

线程调度

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度和抢占式线程调度.
协同式调度的系统,线程的执行时间由线程本身来控制,线程把工作执行完毕,主动通知系统切换到另一个线程上去,当一个线程出现问题会一直阻塞.
抢占式调度的系统,每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定.java使用的就是抢占式调度方式.java设置了10个级别的线程优先级,当两个线程同时处于Ready状态时,优先级越高的越容易被系统选择执行

临界区与竞态条件

临界区 Critical Section

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

竞态条件Race Condition

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

线程切换带来的原子性问题

由于 IO 太慢,早期的操作系统就发明了多进程,即便在单核的 CPU 上我们也可以一边听 着歌,一边写 Bug,这个就是多进程的功劳。 操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选 择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片
notion image
在一个时间片内,如果一个进程进行一个 IO 操作,例如读个文件,这个时候该进程可以 把自己标记为“休眠状态”并出让 CPU 的使用权,待文件读进内存,操作系统会把这个休 眠的进程唤醒,唤醒后的进程就有机会重新获得 CPU 的使用权了。
这里的进程在等待 IO 时之所以会释放 CPU 使用权,是为了让 CPU 在这段等待时间里可 以做别的事情,这样一来 CPU 的使用率就上来了;此外,如果这时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务, 就会立即启动下一个读操作,这样 IO 的使用率也上来了。
是不是很简单的逻辑?但是,虽然看似简单,支持多进程分时复用在操作系统的发展史上却具有里程碑意义,Unix 就是因为解决了这个问题而名噪天下的。
早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任 务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。
现代的操作系统都基于更轻量的线程来调度,现在我 们提到的“任务切换”都是指“线程切换”。
Java 并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟 然也是并发编程里诡异 Bug 的源头之一。任务切换的时机大多数是在时间片结束的时候, 我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成,例 如上面代码中的count += 1,至少需要三条 CPU 指令。
指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
指令 2:之后,在寄存器中执行 +1 操作;
指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)
操作系统做任务切换,可以发生在任何一条CPU 指令执行完,是的,是 CPU 指令,而不 是高级语言里的一条语句。对于上面的三条指令来说,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现 两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。
notion image
我们潜意识里面觉得 count+=1 这个操作是一个不可分割的整体,就像一个原子一样,线 程的切换可以发生在 count+=1 之前,也可以发生在 count+=1 之后,但就是不会发生 在中间。我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性

变量的线程安全分析

成员变量和静态变量

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

局部变量

  • 局部变量是线程安全的
  • 但局部变量引用的对象则未必
    • 如果对象没有逃离方法的作用范围,它是线程安全的
    • 如果该对象逃离方法的作用范围,需要考虑线程安全
案例1
notion image
notion image

局部变量引用对象

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); } } public class TestThreadSafe { 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+1)).start(); } } }
notion image
将list修改为局部变量
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) { System.out.println(1); list.remove(0); } }
  • list是局部变量.每个线程调用时会创建其不同的实例,没有共享
  • 而method2的参数是从method1中传递过来的,与method1中引用同一个对象
  • method3的参数分析与method2相同
    • notion image

权限修饰符和final关键字对线程安全的影响

添加子类继承ThreadSafe,子类覆盖method2method3方法
public class TestThreadSafe { static final int THREAD_NUMBER = 2; static final int LOOP_NUMBER = 200; public static void main(String[] args) { ThreadSafeSubClass test = new ThreadSafeSubClass(); for (int i = 0; i < THREAD_NUMBER; i++) { new Thread(() -> { test.method1(LOOP_NUMBER); }, "Thread" + (i+1)).start(); } } } 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) { System.out.println(1); list.remove(0); } } class ThreadSafeSubClass extends ThreadSafe{ @Override public void method3(ArrayList<String> list) { System.out.println(2); new Thread(() -> { list.remove(0); }).start(); } @Override public void method2(ArrayList<String> list) { new Thread(() -> { list.add("1"); }).start();//每次循环都会创建新的线程,且这些线程共享list对象 } } // //2 //2 //2 //2 //2 //2 //2 //Exception in thread "Thread-616" 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.n4.ThreadSafeSubClass.lambda$method3$0(TestThreadSafe.java:60) //at java.lang.Thread.run(Thread.java:748)
将method2和method3访问权限符改为private,或者将method2和method3添加final,使其无法被子类覆写
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) { System.out.println(1); list.remove(0); } } class ThreadSafeSubClass extends ThreadSafe{ //@Override public void method3(ArrayList<String> list) { System.out.println(2); new Thread(() -> { list.remove(0); }).start(); } //@Override public void method2(ArrayList<String> list) { new Thread(() -> { list.add("1"); }).start(); } } //output //1 //1 //1 //1 //1 //1 //1 //1 //1

常见线程安全类

  • 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();

线程安全类方法的组合

线程安全的方法组合中间方法切换调换的时候会释放锁,方法的组合代码块无法保证原子性
Hashtable table = new Hashtable(); new Thread(() -> { if (table.get("key") == null) { table.put("key", "1"); } }).start(); new Thread(() -> { if (table.get("key") == null) { table.put("key", "2"); } }).start(); sleep(1); System.out.println(table.get("key")); }
notion image

不可变类线程安全性

String,Integer等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的
public class Immutable { private int value = 0; public Immutable(int value){ this.value = value; } public int getValue() { return this.value; } public Immutable add(int v){ //新增方法都是重新创建对象 return new Immutable(this.value + v); } }

实例分析

例1
notion image
例2
HttpServlet为单例对象,多次请求会涉及到单例对象属性count的修改
notion image
例3
spring中的单例对象,涉及到其属性的修改也不是线程安全的
notion image
例4 线程安全
notion image
例5 线程不安全
notion image
例6 线程安全
notion image
例子7:
notion image
notion image

卖票问题

@Slf4j(topic = "c.ExerciseSell") public class ExerciseSell { public static void main(String[] args) throws InterruptedException { // 模拟多人买票 TicketWindow window = new TicketWindow(1000); // 所有线程的集合 List<Thread> threadList = new ArrayList<>(); // 卖出的票数统计 List<Integer> amountList = new Vector<>(); for (int i = 0; i < 2000; i++) { Thread thread = new Thread(() -> { // 买票 int amount = window.sell(random(5)); // 统计买票数 amountList.add(amount); }); threadList.add(thread); thread.start(); } for (Thread thread : threadList) { thread.join(); } // 统计卖出的票数和剩余票数 log.debug("余票:{}",window.getCount()); log.debug("卖出的票数:{}", amountList.stream().mapToInt(i-> i).sum()); } // Random 为线程安全 static Random random = new Random(); // 随机 1~5 public static int random(int amount) { return random.nextInt(amount) + 1; } } // 售票窗口 class TicketWindow { private int count; public TicketWindow(int count) { this.count = count; } // 获取余票数量 public int getCount() { return count; } // 售票 public synchronized int sell(int amount) { if (this.count >= amount) { this.count -= amount; return amount; } else { return 0; } } }

转账问题

@Slf4j(topic = "c.ExerciseTransfer") public class ExerciseTransfer { public static void main(String[] args) throws InterruptedException { Account a = new Account(1000); Account b = new Account(1000); Thread t1 = new Thread(() -> { for (int i = 0; i < 1000; i++) { a.transfer(b, randomAmount()); } }, "t1"); Thread t2 = new Thread(() -> { for (int i = 0; i < 1000; i++) { b.transfer(a, randomAmount()); } }, "t2"); t1.start(); t2.start(); t1.join(); t2.join(); // 查看转账2000次后的总金额 log.debug("total:{}", (a.getMoney() + b.getMoney())); } // Random 为线程安全 static Random random = new Random(); // 随机 1~100 public static int randomAmount() { return random.nextInt(100) + 1; } } // 账户 class Account { private int money; public Account(int money) { this.money = money; } public int getMoney() { return money; } public void setMoney(int money) { this.money = money; } // 转账 public void transfer(Account target, int amount) { synchronized(Account.class) { if (this.money >= amount) { this.setMoney(this.getMoney() - amount); target.setMoney(target.getMoney() + amount); } } } }
阻塞和非阻塞通常用来形容线程间的相互影响。比如一个线程占用了临界区资源,那么其他所有需要这个资源的线程就必须在这个临界区中进行等待,等待会导致线程挂起,这种情况就是阻塞。此时,如果占用资源的线程一直不愿意释放资源,那么其它所有阻塞在这个临界区上的线程都不能工作。
非阻塞允许多个线程同时进入临界区