目录
♫引发线程安全的主要原因
♫synchronized关键字
♪什么是synchronized
♪synchronized的特性
♫Java标准库的线程安全类
♫死锁问题
♪什么是死锁
♪死锁的必要条件
♪避免死锁的条件
♫volatile关键字
单线程的程序代码执行顺序是固定的,而由于多线程是并发执行、随机调度的,故多线程的代码执行顺序有许多种,只要有一种调度顺序的执行结果不符合预期,都将视为BUG,从而引发线程安全问题。
引发线程安全的原因主要有以下几种:
♪根本原因:线程的强占式执行,随机调度。这个是操作系统实现的,我们无能为力╮(╯▽╰)╭
♪代码结构:多个线程同时修改一个变量(修改操作不是原子性(不可再分的最小操作)的,分为读,改,写,这样可能导致一个线程读取的变量是另一个线程写入前的变量)。我们可以通过适当调整代码结构来避免多线程同时修改一个变量,但是这种调整不一定都能用,所以不具有普遍性。
♪原子性:如果某个操作(如:修改操作)是原子性的,那么线程安全问题就迎刃而解了。Java中可以通过synchronized关键字对某个操作进行加锁,从而实现该操作的原子性。
♪内存可见性:一个线程对共享变量的修改,不能即时被其它线程看到(如:一个线程将一个变量从内存读取到寄存器或cache中,另一个线程对该变量进行修改,由于CPU直接访问寄存器或cache的速率比访问内存快很多,故编译器可能直接读取寄存器或cache中的变量)。Java中可以通过volatile关键字让编译器每次都从内存中读取该变量,解决内存可见性问题。
♪指令重排序:指令重排序本质上是编译器对代码的优化,编译器觉得你代码写的太low了,保证逻辑不变的情况下,调整你的代码以提高程序的运行速度(在单线程情况下还好,由于多线程的随机调度,程序的执行顺序千变万化,因此在多线程环境下编译器并不能很好地保证修改后程序的执行逻辑)。Java中通过volatile关键字也可以解决指令重排序问题。
♪什么是synchronized
synchronized 也叫监视器锁 monitor lock,会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待。
下面代码是两个线程(t1和t2)对同一个变量(count)进行修改(都令count自增5000次):
class Counter{ private int count = 0; public void add() { count++; } public int getCount() { return count; } } public class Test { public static void main(String[] args) { Counter counter = new Counter(); Thread t1 = new Thread(()->{ for (int i = 0; i < 5000; i++) { counter.add(); } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 5000; i++) { counter.add(); } }); t1.start(); t2.start(); try { t1.join(); t2.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(counter.getCount()); } }
运行结果1:
运行结果2:
运行结果3:
可以看到,每次运行的结果都不符合预期且不同的,这就是线程的随机调度造成的:上述代码中count++操作可以细分为3步骤(①.load:读取内存中的count值到cup的寄存器中 ②.add:cpu寄存器中的count值加一 ③.save:将count的结果写入到内存中),两个线程之间调度这3个步骤的顺序有无数种可能:
可见,除了第①种调度顺序符合预期外,其他的调度顺序均会使两次或多次++操作只达到一次++操作的效果,要想让所有结果均符合预期,就得让每个线程的load、add、save操作像第①种这样连着执行,即要保证++操作的原子性(一个整体,不能再分)。接下来就从原子性入手,解决这个代码的线程安全问题。
Java里通过synchronized关键字对方法或者代码块进行加锁,把表示原子的转换成原子的。
下面是通过synchronized关键字对方法进行加锁(语法:synchronized 访问修饰符 返回类型 方法名(...){...}或访问修饰符 synchronized 返回类型 方法名(...){...}):
class Counter{ private int count = 0; //加了synchronized后进入add方法加锁,出了add方法解锁 synchronized public void add() { count; } public int getCount() { return count; } } public class Test { public static void main(String[] args) { Counter counter = new Counter(); Thread t1 = new Thread(()->{ for (int i = 0; i < 5000; i++) { counter.add(); } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 5000; i++) { counter.add(); } }); t1.start(); t2.start(); try { t1.join(); t2.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(counter.getCount()); } }
运行结果:
上面对add方法加了synchronized关键字后,一旦有线程进入add方法后就会对该方法进行加锁,之后其他线程要进入该方法就只能阻塞等待(BLIOK),直到前一个线程解锁(执行完add方法)才能加锁成功。这样就避免了脏读问题。
下面是通过synchronized关键字对代码块进行加锁(语法:synchronized(加锁对象){...}):
class Counter{ private int count = 0; public void add() { synchronized (this) { count++; } } public int getCount() { return count; } } public class Test { public static void main(String[] args) { Counter counter = new Counter(); Thread t1 = new Thread(()->{ for (int i = 0; i < 5000; i++) { counter.add(); } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 5000; i++) { counter.add(); } }); t1.start(); t2.start(); try { t1.join(); t2.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(counter.getCount()); } }
运行结果:
♪synchronized的特性
①.加了synchronized之后,多了加锁、解锁操作,会有额外开销
②.加了synchronized之后,若获取不到锁,则会一直阻塞等待,直到获取锁为止;而Java中还有一种锁ReenTrantLock则是获取不到锁就放弃了
③.加锁操作需要明确加锁对象,修饰普通方法加锁对象就是this,修饰类方法加锁对象就是类对象,修饰代码块需要显示指定加锁对象
④.假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则
⑤.只有两个线程针对同一对象加锁才会产生锁冲突,两个线程针对不同对象则不会产生锁冲突
⑥.synchronized是可重入锁,即一个线程可以对同一个对象反复加锁(在可重入锁的内部包含了 "线程持有者" 和 "计数器" 两个信息,如果某个线程加锁的时候发现锁已经被人占用,但是恰好占用的正是自己,那么仍然可以继续获取到锁,并让计数器自增,解锁的时候计数器递减为 0 的时候,才真正释放锁)
//对count++方法重复加锁 synchronized public void add() { synchronized (this) { count++; } }
Java 标准库中很多都是线程不安全的 . 这些类可能会涉及到多线程修改共享数据 , 又没有任何加锁措施:1.ArrayList 2.LinkedList 3.HashMap 4.TreeMap 5.HashSet 6.TreeSet 7.StringBuilder但是还有一些是线程安全的 . 使用了一些锁机制来控制 .1.Vector ( 不推荐使用 ) 2.HashTable (不推荐使用 ) 3.ConcurrentHashMap 4.StringBuffer还有的虽然没有加锁 , 但是不涉及 " 修改 ", 仍然是线程安全的1.String
♪什么是死锁
死锁是指线程之间互相等待对方释放资源而无法继续进行的情况。死锁问题是操作系统中的一个重要问题,因为它可能导致系统崩溃或者无法正常工作,可以大致分为以下两种情况:
♩.一个线程一把锁
一个线程对同一锁反复加锁,由于Java中synchronized和ReentrantLock都是可重入锁,故在Java中可以不去考虑这种情况。但在C++、Python、操作系统原生的加锁API都是不可重入的,这就得去考虑这种情况。
♩.两个线程两把锁
t1和t2两个线程分别获取锁a和锁b后,t1再尝试获取锁b,t2再尝试获取锁a,t1等待t二释放锁B,t2等待t1释放锁A:
public class Test { public static void main(String[] args) throws InterruptedException { Object a = new Object(); Object b = new Object(); Thread t1 = new Thread(()->{ synchronized (a) { System.out.println("t1获取到了锁a"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (b) { System.out.println("t1获取到了锁b"); } } }); Thread t2 = new Thread(()->{ synchronized (b) { System.out.println("t2获取到了锁b"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (a) { System.out.println("t2获取到了锁a"); } } }); t1.start(); t2.start(); Thread.sleep(5000); //打印线程状态 System.out.println(t1.getState()); System.out.println(t2.getState()); } }
运行结果:
t1在等t2释放锁b,而t2又在等t1释放锁a,这就发生了死锁现象。
注:由于线程是并发执行的,故t1线程里sleep(1000)是为了降低t1线程比t2线程先获取到锁b的概率,t2线程里sleep(1000)是为了降低t2线程比t1线程先获取到锁a的概率,主线程里的sleep(5000)是为了打印t1和t2线程死锁后的状态
♩.多个线程多把锁
哲学家就餐问题:多个哲学家在共享的圆桌上用餐,每个哲学家需要先拿起它左右两边的筷子,才能开始吃饭,极端情况下,同一时刻所有哲学家都拿起它左边的筷子,每个哲学家都在等待右边的筷子:
public class Test { public static void main(String[] args) throws InterruptedException { Object a = new Object(); Object b = new Object(); Object c = new Object(); Thread t1 = new Thread(()->{ synchronized (a) { System.out.println("哲学家t1拿起了左边的筷子a"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (c) { System.out.println("哲学家t1拿起了右边的筷子c"); } } }); Thread t2 = new Thread(()->{ synchronized (b) { System.out.println("哲学家t2拿起了左边的筷子b"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (a) { System.out.println("哲学家t2拿起了右边的筷子a"); } } }); Thread t3 = new Thread(()->{ synchronized (c) { try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } System.out.println("哲学家t3拿起了左边的筷子c"); synchronized (b) { System.out.println("哲学家t3拿起了右边的筷子b"); } } }); t1.start(); t2.start(); t3.start(); Thread.sleep(5000); System.out.println(t1.getState()); System.out.println(t2.getState()); System.out.println(t3.getState()); } }
♪死锁的必要条件
引发死锁的必要条件有以下四点:
♩.互斥使用:线程1拿到了锁,线程2要想获取该锁就得第线程二释放该锁
♩.不可抢占:线程1拿到了锁,不主动释放就不会被线程2抢走
♩.请求和保持:线程1拿到了锁A,再去获取锁B,A锁不会因为获取到B锁而释放掉
♩.循环等待:线程1拿到了锁A,线程2拿到了锁B,线程1再尝试获取锁B,线程2再尝试获取锁A,线程1等待线程二释放锁B,线程2等待线程1释放锁A
♪避免死锁的条件
互斥使用、不可抢占与请求和保持均是synchronized的特性,所有要避免死锁就得从循环等待入手。我们可以给锁编号,然后指定一个固定的顺序(如:从小到大)来加锁,任意线程加多把锁时都遵循上述顺序,这样就能避免循环等待。(如:给哲学家问题中的筷子编号,哲学家优先抢占编号小的筷子(没抢到就阻塞等待),这样就会有一个哲学家能拿到两双筷子)
volatile关键字是用来保证内存可见性的,加上volatile会强制读写内存,虽然读取速度变慢了,但是读取的数据更准确了。
t1线程读取变量a到寄存器中判断是否为0,由于a变量长时间未变化,故后续循环判断时,编译器就直接根据寄存器中的值来判断a是否为0:
public class Test { public static int a = 0; public static void main(String[] args) { Thread t1 = new Thread(()->{ while (a == 0) { } System.out.println("t1退出循环"); }); Thread t2 = new Thread(()->{ Scanner sc = new Scanner(System.in); a = sc.nextInt(); }); t1.start(); t2.start(); } }
运行结果:
可以看到,即使t2线程修改了变量a的值为2,t1线程判断循环条件时读取到的a的值仍然为0。通过volatile关键字就可以强制让线程每次都从变量中读取值:
public class Test { public static volatile int a = 0; public static void main(String[] args) { Thread t1 = new Thread(()->{ while (a == 0) { } System.out.println("t1退出循环"); }); Thread t2 = new Thread(()->{ Scanner sc = new Scanner(System.in); a = sc.nextInt(); }); t1.start(); t2.start(); } }
运行结果:
注:内存可见性本质是编译器优化,编译器优化也不是什么时候都会有,但对可能涉及到内存可见性问题的,最稳妥的做法还是加上volatile