上一篇传送门: 专治Java底子差,线程操作篇(1)
三、线程安全
3.1 线程安全问题
我们前面的操作线程与线程间都是互不干扰,各自执行,不会存在线程安全问题。当多条线程操作同一个资源时,发生写的操作时,就会产生线程安全问题;
我们来举一个案例,从广州开往南昌的票数共有100张票,售票窗口分别有“广州南站”、“广州北站”、“广州站”等。
- 定义卖票任务:
package com.dfbz.demo01_线程安全问题引入; /** * @author lscl * @version 1.0 * @intro: */ public class Ticket implements Runnable { //票数 private Integer ticket = 1000; @Override public void run() { while (true) { if (ticket <= 0) { break; //票卖完了 } System.out.println(Thread.currentThread().getName() + "正在卖第: " + (1001 - ticket) + "张票"); ticket--; } } }
- 测试类:
package com.dfbz.demo01_线程安全问题引入; /** * @author lscl * @version 1.0 * @intro: */ public class Demo01_卖票案例 { public static void main(String[] args) { Ticket ticket = new Ticket(); //开启三个窗口,买票 Thread t1 = new Thread(ticket, "广州南站"); Thread t3 = new Thread(ticket, "广州北站"); Thread t2 = new Thread(ticket, "广州站"); t1.start(); t2.start(); t3.start(); } }
查看运行结果:
发现程序出现了两个问题:
- 有的票卖了多次
- 卖票顺序不一致
分析卖了多次票:
分析卖票顺序不一致:
3.2 线程同步
当我们使用多个线程访问同一资源的时候,且多个线程中对资源有写的操作,就容易出现线程安全问题。要解决上述多线程并发访问一个资源的安全性问题:也就是解决重复票与不存在票问题,Java中提供了同步机制(synchronized)来解决。
根据案例简述:窗口1线程操作的时候,窗口2和窗口3线程只能在外等着,窗口1操作结束,窗口1和窗口2和窗口3才有机会进入代码去执行。也就是说在某个线程修改共享资源的时候,其他线程不能修改该资源,等待修改完毕同步之后,才能去抢夺CPU资源,完成对应的操作,保证了数据的同步性,解决了线程不安全的现象。
Java中提供了三种方式完成同步操作:
- 同步代码块。
- 同步方法。
- 锁机制。
3.2.1 同步代码块
1)同步代码块改造买票案例
- 同步代码块:
synchronized
关键字可以用于方法中的某个区块中,表示只对这个区块的资源实行互斥访问。
语法:
synchronized(同步锁){ 需要同步操作的代码 }
同步锁:
对象的同步锁只是一个概念,可以想象为在对象上标记了一个锁;
- 锁对象可以是任意类型。
- 多个线程对象 要使用同一把锁。
注意:在任何时候,最多允许一个线程拥有同步锁,谁拿到锁就进入代码块,其他的线程只能在外等着(BLOCKED)。
使用同步代码块改造代码:
package com.dfbz.demo01_线程安全问题引入; /** * @author lscl * @version 1.0 * @intro: */ public class Ticket implements Runnable { //票数 private Integer ticket = 1000; //锁对象 private Object obj = new Object(); @Override public void run() { while (true) { // 加上同步代码块,把需要同步的代码放入代码块中,同步代码块中的锁对象必须保证一致! synchronized (obj) { if (ticket <= 0) { break; // 票卖完了 } System.out.println(Thread.currentThread().getName() + "正在卖第: " + (1001 - ticket) + "张票"); ticket--; } } } }
2)同步代码块案例
案例:要么输出"犯我中华者",要么输出"虽远必诛"
package com.dfbz.demo02_线程安全; /** * @author lscl * @version 1.0 * @intro: */ public class Demo01_同步代码块小案例 { public static void main(String[] args) { new Thread() { @Override public void run() { for (int i = 0; i < 10000; i++) { synchronized (String.class) { System.out.print("我"); System.out.print("是"); System.out.print("中"); System.out.print("国"); System.out.print("人"); System.out.println(); } } } }.start(); new Thread() { @Override public void run() { for (int i = 0; i < 10000; i++) { synchronized (String.class) { System.out.print("犯"); System.out.print("我"); System.out.print("中"); System.out.print("华"); System.out.print("者"); System.out.println(); } } } }.start(); } }
3)字节码对象
在使用同步代码块时,必须保证锁对象是同一个,才能实现线程的同步,不能使用不同的对象来锁不同的代码块;那么有什么对象只会存在一份的吗?答:任何类的字节码对象;
任何类的字节码对象都只会存在一次,在类加载的时候由JVM创建的;因此字节码锁也称为万能锁;
- 获取一个类的字节码对象有三种方式:
package com.dfbz.demo02_线程安全; /** * @author lscl * @version 1.0 * @intro: */ public class Demo02_字节码对象 { public static void main(String[] args) throws ClassNotFoundException { // 获取字节码对象的1种方式 Class<String> c1 = String.class; // 获取字节码对象的2种方式 String str = new String(); Class<? extends String> c2 = str.getClass(); // 获取字节码对象的3种方式 Class<?> c3 = Class.forName("java.lang.String"); System.out.println(c1 == c2); // true System.out.println(c1 == c3); // true } }
Tips:以上三种方式都是获取JVM创建的字节码对象,而不是创建一个字节码对象,所有类的字节码对象都是在类加载的时候由JVM创建的;
- 使用字节码对象来作为锁对象:
package com.dfbz.demo02_线程安全; /** * @author lscl * @version 1.0 * @intro: */ public class Demo03_使用字节码对象作为锁 { public static void main(String[] args) { new Thread(()->{ for (int i = 0; i < 100; i++) { synchronized (Object.class) { // 使用字节码对象作为锁对象 System.out.print("犯"); System.out.print("我"); System.out.print("中"); System.out.print("华"); System.out.print("者"); System.out.println(); } } }).start(); new Thread(()->{ for (int i = 0; i < 100; i++) { synchronized (Object.class) { // 使用字节码对象作为锁对象 System.out.print("虽"); System.out.print("远"); System.out.print("必"); System.out.print("诛"); System.out.println(); } } }).start(); } }
3.2.2 同步方法
1)普通同步方法
- 同步方法:使用
synchronized
修饰的方法,就叫做同步方法,保证A线程执行该方法的时候,其他线程只能在方法外等着。
注意:同步方法也是有锁对象的,对于静态方法的锁对象的当前类的字节码对象(.class),对于非静态的方法的锁对象是this;
语法:
public synchronized void method(){ 可能会产生线程安全问题的代码 }
使用同步方法:
package com.dfbz.demo02_线程安全; /** * @author lscl * @version 1.0 * @intro: */ public class Demo04_同步方法 { public static void main(String[] args) { Shower shower = new Shower(); new Thread() { @Override public void run() { for (int i = 0; i < 1000; i++) { shower.print1(); } } }.start(); new Thread() { @Override public void run() { for (int i = 0; i < 1000; i++) { shower.print2(); } } }.start(); } } class Shower { // 普通方法的锁对象是this public synchronized void print1() { System.out.print("犯"); System.out.print("我"); System.out.print("中"); System.out.print("华"); System.out.print("者"); System.out.println(); } public void print2() { synchronized (this) { System.out.print("虽"); System.out.print("远"); System.out.print("必"); System.out.print("诛"); System.out.println(); } } }
2)静态同步方法
普通同步方法的锁对象是当前对象的引用(this),静态同步方法的锁对象是当前类的字节码对象;
- 示例代码:
package com.dfbz.demo02_线程安全; /** * @author lscl * @version 1.0 * @intro: */ public class Demo05_静态同步方法 { public static void main(String[] args) { Print print = new Print(); new Thread() { @Override public void run() { for (int i = 0; i < 1000; i++) { print.print1(); } } }.start(); new Thread() { @Override public void run() { for (int i = 0; i < 1000; i++) { print.print2(); } } }.start(); } } class Print{ // 静态同步方法的锁对象是当前类的字节码对象 public static synchronized void print1() { System.out.print("犯"); System.out.print("我"); System.out.print("中"); System.out.print("华"); System.out.print("者"); System.out.println(); } public void print2() { synchronized (Print.class) { System.out.print("虽"); System.out.print("远"); System.out.print("必"); System.out.print("诛"); System.out.println(); } } }
3.2.3 Lock锁
java.util.concurrent.locks.Lock
机制提供了比synchronized代码块和synchronized方法更广泛的锁定操作,它是用于管理和控制线程之间共享资源的同步机制之一。 与传统的synchronized关键字相比,Lock
接口提供了更细粒度的控制,例如公平性、可重入性等。此外,Lock
接口还支持中断锁的获取和超时获取,这是synchronized关键字所不具备的功能。
Lock加锁与释放锁方法化了,如下:
public void lock()
:加同步锁。public void unlock()
:释放同步锁。
示例代码:
package com.dfbz.demo02_线程安全; import java.util.concurrent.locks.ReentrantLock; /** * @author lscl * @version 1.0 * @intro: */ public class Demo06_lock锁 { public static void main(String[] args) { ReentrantLock lock = new ReentrantLock(); new Thread() { @Override public void run() { for (int i = 0; i < 10000; i++) { lock.lock(); System.out.print("我"); System.out.print("是"); System.out.print("中"); System.out.print("国"); System.out.print("人"); System.out.println(); lock.unlock(); } } }.start(); new Thread() { @Override public void run() { for (int i = 0; i < 10000; i++) { lock.lock(); System.out.print("犯"); System.out.print("我"); System.out.print("中"); System.out.print("华"); System.out.print("者"); System.out.println(); lock.unlock(); } } }.start(); } }
3.2.4 线程死锁
多线程同步的时候,如果同步代码嵌套,使用相同锁,就有可能出现死锁;
- 分析:
- 示例代码:
package com.dfbz.demo02_线程安全; /** * @author lscl * @version 1.0 * @intro: */ public class Demo07_线程死锁 { public static void main(String[] args) { String s1 = "s1"; String s2 = "s2"; new Thread() { public void run() { while (true) { synchronized (s1) { // ①线程1先获取到s1锁 System.out.println(this.getName() + "s1"); synchronized (s2) { // ③线程1继续执行,被锁阻塞 System.out.println(this.getName() + "s2"); } } } } }.start(); new Thread() { public void run() { while (true) { synchronized (s2) { // ②线程2获取到s2锁 System.out.println(this.getName() + "s2"); synchronized (s1) { // ④线程2继续执行,被锁阻塞(死锁) System.out.println(this.getName() + "s1"); } } } } }.start(); } }
3.3 集合的线程安全问题
3.3.1 线程安全与不安全集合
我们前面学习集合的时候发现集合存在由线程安全集合和线程不安全集合;线程安全效率低,安全性高;反之,线程不安全效率高,安全性低,线程不安全的集合有:Vector,Stack,Hashtable等;
- 查看Vector和Hashtable等源代码:
线程安全集合中的方法大部分都加上了synchronized
关键字来保证线程的同步;
- 线程不安全集合:
3.3.2 线程不安全集合测试
- 数据覆盖问题:
package com.dfbz.demo03_集合与线程安全问题; import java.util.ArrayList; /** * @author lscl * @version 1.0 * @intro: */ public class Demo01_测试ArrayList线程不安全问题 { public static void main(String[] args) { ArrayList<String> arr = new ArrayList<>(); for (int j = 0; j < 200; ++j) { new Thread(() -> { for (int i = 0; i < 100; i++) { arr.add("1"); try { // 让线程安全问题更加突出 Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); } } }
运行代码,发现出现数组下标越界异常:
分析ArrayList源码:
①假设此时size为9(集合已经存储了9个元素,本次是来存储第10个元素),size+1并没有大于数组的默认长度(10),并没有造成数组的扩容
②等待代码将集合的9下标赋值后,size++还没来得及运算,CPU的执行权就被其他的线程抢走了,此时size仍旧为9,但此时集合中已经存储了10个元素了;
③等到其他线程来执行ensureCapacityInternal(9+1)--->ensureCapacityInternal--->ensureExplicitCapacity发现10-10还是小于0,依旧不扩容
④代码执行elementData[size++]=e时(还没执行),线程执行权又回到了第一条线程,size++,变为10
⑤然后线程执行权又变回执行elementData[size++]=e
这段代码时的那个线程,出现了elementData[10]=e,出现数组下标越界;
Tips:HashMap同样会出现这个问题,将集合换成Vector或者Stack等线程安全集合可以解决这些问题;或者使用JDK提供的其他线程同步集合也可以解决这些问题;