八、解决线程不安全
程序清单9:
public class Test { static class Counter { public int count = 0; //让 count 变量自增 synchronized public void increase() { count++; } } static Counter counter = new Counter(); public static void main(String[] args) { //线程1 自增 5w 次 Thread t1 = new Thread() { @Override public void run() { for (int i = 0; i < 50000; i++) { counter.increase(); } } }; //线程2 自增 5w 次 Thread t2 = new Thread() { @Override public void run() { for (int i = 0; i < 50000; i++) { counter.increase(); } } }; t1.start(); t2.start(); try { t1.join(); t2.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(counter.count); } }
我们在程序清单9 中,为 increase 方法加上了 synchronized 修饰符,可以看到输出结果就是 10000.
输出结果:
上述解决线程不安全,就是通过 " 原子性 " 这样的切入点来解决问题,synchronized 的英文原意为 【adj. 同步的】。而它在计算机中的术语,我们可以将它理解成 " 互斥 "。
如果两个线程同时并发地尝试调用这个 synchronized 修饰的方法,此时一个线程会先执行这个方法,另外一个线程会等待,等到第一个线程方法执行完了之后,第二个线程才会继续执行这个方法。而这个过程实际上就相当于" 加锁 " 和 " 解锁 "。
举个例子,你去银行 ATM 取钱,当你进入隔间的时候,需要把门带上,而后面的人需要排队等待。这就像 synchronized 的加锁一样。synchronized 本质上就是,将 " 并发执行 " 变成 " 串行执行 ",这样一来,速度就会降低。在刚刚银行的例子中,假设你的账户有10000 元,你取出了 5000元,本来你应该剩余 5000 元的,但你取钱之后发现就剩了 1000元,这是什么感受?所以当你需要一些准确无误的结果的时候,你必须这样。那么使用 synchronized 关键字的场景实际上就是:两个线程竞争同一把锁,而可能出现阻塞的场景。这就像下图一样,一家小银行,只有一个 ATM 机。
1. synchronized 关键字
在 Java 中,进入 synchronized 修饰的方法,就相当于加锁;出了 synchronized 修饰的方法,就相当于解锁。如果当前是已经加锁的状态,其他的线程就无法执行这里的逻辑,就只能阻塞等待。
在上面的代码中,synchronized 被用来修饰方法,而它还能用来修饰代码块。
当它用来修饰代码块的时候,我们需要显示地在 ( ) 中指定一个加锁的对象,如果 synchronized 直接修饰的是非静态方法,相当于加锁的对象就是 this.
public void increase() { synchronized (this) { count++; } }
所谓的 " 加锁操作 " 其实就是把一个指定的锁对象中的锁标记设为 true.
所谓的 " 解锁操作 " 其实就是把一个指定的锁对象中的锁标记设为false.
如果两个线程尝试针对同一个锁对象进行加锁,此时一个线程会先获取到锁,另外一个线程就阻塞等待。这就和上面的例子差不多,假设银行只有一台 ATM,那么许多人就要排队等待。
如果两个线程,尝试针对两个不同对象进行加锁,此时两个线程都能获取到各自的锁,互不冲突。这就和银行有多台 ATM 一样,不同的人去不同的隔间取钱。
拓展:Java 中任意的对象,都可以作为 " 锁对象 ",这一点就和其他语言的设定不一样,例如 C++,Python,GO … 这些语言的加锁操作,就只能针对特定的对象加锁。
2. synchronized 主要的三个特性
(1) 互斥
互斥这一特性即对应到程序清单9 中的代码,也就是说,它解决了线程安全问题中的非原子性,即表示一个线程在执行某一步骤的过程中,在执行完之前,其他线程阻塞等待。
(2) 刷新内存,保证内存的可见性
刷新内存这一特性,指的是:synchronized 还能刷新内存,解决内存可见性的问题。举个例子:一个线程修负责改,一个负责线程读取。由于编译器的优化,可能把一些中间环节的 LOAD 和 RETURN操作取消掉了,此时读的线程可能读到的就是未修改的结果。加上 synchronized 之后,就会禁止编译器优化,保证每次进行操作的时候,都会把数据真的从内存读,也真的写回内存中。这样一来,同样地,程序运行速度会变慢,但是求得的结果和预期之间较为准确。
这解决了线程安全问题中的内存不可见性。
(3) 可重入( 防止死锁 )
对于第三点,我们对 increase 方法加了 synchronized,同时,在方法的里面,我们又加了一个 synchronized,在 Java 中,其实这样没有问题,它防止了死锁现象。
死锁:
第一次加锁,加锁成功。
第二次再尝试针对这个线程加锁的时候,此时对象头的锁标记已经是true,按照咱们之前的理解,其他线程就要阻塞等待,等待这个锁标记被改成 false,然后再重新竞争这个锁…所以说:本质上第 ② 步不会执行,因为它正在阻塞等待,那么我们就走不到最后那个花括号( 红色箭头 ),这样一来,对于第一个 synchronized 来说,我们就无法 " 解锁 ",这样就会造成死锁。
然而,在 Java 中,它就是为了防止程序员犯错,所以体现了可重入性,解决了当前逻辑的死锁现象。
3. synchronized 的使用示例
(1) 直接修饰普通方法
将 SynchronizedDemo 对象加锁
public class SynchronizedDemo { public synchronized void method() { } }
这个时候如果两个线程并发地调用这个方法,此时是否会触发锁竞争,就要看实际的锁对象是否是同一个了。
(2) 修饰静态方法
将 SynchronizedDemo 类的对象加锁
public class SynchronizedDemo { public synchronized static void method() { } }
由于类对象是单例的,两个线程并发调用该方法,一定会触发锁竞争。因为 static 修饰的方法直接关联到类。
(3) 修饰代码块
① 将当前对象加锁
public class SynchronizedDemo { public void method() { synchronized (this) { } } }
② 将类的对象加锁
public class SynchronizedDemo { public void method() { synchronized (SynchronizedDemo.class) { } } }
4. volatile 关键字
程序清单10:
public class Test { static class Counter { public int flag = 0; } public static void main(String[] args) { Counter counter = new Counter(); Thread t1 = new Thread() { @Override public void run() { while (counter.flag == 0){ } System.out.println("循环结束..."); } }; t1.start(); Thread t2 = new Thread() { @Override public void run() { Scanner scanner = new Scanner(System.in); System.out.println("请输入一个整数: "); counter.flag = scanner.nextInt(); } }; t2.start(); } }
输出结果:
如果正常情况下,按照上面的代码,我们输入的值为 1,即 flag 不为 0,那么就会输出
【循环结束…】,可是我们可以看到输出结果什么都没有。
图解分析:
static class Counter { volatile public int flag = 0; }
所以我们就将 flag 加上 volatile 修饰即可,一旦加上 volatile 关键字之后,此时后续针对 flag 的读写操作,就能保证一定是操作内存了。
新的输出结果:
总结:volatile 关键字用法较为单一,它只能用来修饰属性 / 成员变量。它可以保证内存可见性,但是保证不了原子性。
程序清单11:
public class Test { static class Counter { public int flag = 0; } public static void main(String[] args) { Counter counter = new Counter(); Thread t1 = new Thread() { @Override public void run() { while (counter.flag == 0){ synchronized (counter) { if (counter.flag != 0) { break; } } } System.out.println("循环结束..."); } }; t1.start(); Thread t2 = new Thread() { @Override public void run() { Scanner scanner = new Scanner(System.in); System.out.println("请输入一个整数: "); counter.flag = scanner.nextInt(); } }; t2.start(); } }
输出结果:
我们可以看到,当我们使用 synchronized 关键字,也是可以保证内存可见性。
总而言之,volatile 关键字和编译器优化密切相关,而编译器优化是一个相当复杂的事情,在我们写出的代码后,编译器优化或不优化,什么时候优化、又什么时候不优化、优化到什么程度,这都是很难去控制的事情。所以还是在日常开发中,多写代码,多总结一些经验才能够慢慢熟悉。一般来说,如果某个变量,在一个线程中读,另一个线程中写,这个时候大概率需要使用 volatile.
volatile关键字 与 JMM 内存模型
volatile 这里涉及到一个重要的知识点,JMM ( Java Memory Model ) 内存模型,当代码中需要读一个变量的时候,不一定是真的在读内存。可能这个数据已经在 CPU 或 cache 中缓存着了,这个时候就可能绕过内存,直接从 CPU 或 cache 中来取这个数据。
然而,JMM 针对计算机的硬件结构又进行了一层抽象 ( 主要就是因为 Java 要考虑到跨平台的问题,要能支持不同的计算 ) 所以,
JMM 把CPU的寄存器,cache 统称为 " 工作内存 ",而工作内存一般不是真正的内存。
JMM 把真正的内存称为 " 主内存 "。
CPU 在和内存交互的时候,经常会把主内存的内容拷贝到工作内存,然后再对数据进行操作,最后才写回到主内存。而这个过程中就非常容易出现数据不一致的情况,这种情况在编译器开启优化的时候会特别严重。
而 volatile 或 synchronized 关键字能够强制保证操作数据的时候,操作的是内存,在生成的java 字节码中强制插入一些 " 内存屏障 " 的指令,而这些指令的效果,就是强制同步主内存和工作内存的内容。
5. synchronized 和 volatile 的区别和联系( 经典面试题 )
① synchronized 既能保证内存可见性,又能保证原子性。
② volatile 只能保证内存可见性,保证不了原子性。
九、wait 和 notify 方法
说明
在 Java 中,wait 和 notify 方法必须要搭配使用,才能合理地协调多个线程之间的执行先后顺序。此外, wait 和 notify 方法必须要针对同一个对象使用。wait 和 notify 都是 Object 类的方法,比如线程1中的对象1 调用了 wait 方法,必须要有个线程2 也调用对象1 的 notify 方法,才能唤醒线程1。这就是规则。
而如果是线程2,调用了对象2 的 notfiy 方法,就无法唤醒线程1.
1. wait 方法
我们使用 wait 方法,它在 Java 底层中,做了一下三件事:
(1) wait 方法让当前线程阻塞等待,因为 CPU 在进行线程调度的时候,是从就绪队列中,找一个PCB 到 CPU 上执行。所以 wait 方法就是将这个线程的 PCB 从就绪队列拿到等待对列中,并准备接受通知。
(2) wait 方法释放当前锁,要想使用 wait / notify,必须搭配 synchronized,需要先获取到锁,才有资格谈 wait,所以在有锁的状态下,wait 方法其实执行了释放锁操作。释放锁的目的就是为了给其他线程让路,也就是说:释放锁之后,所有处于就绪队列的线程需要重新竞争。
举个例子:银行用户去 ATM 取钱,用户1 在取钱的时候,发现 ATM 机子里面没钱了,所以银行就叫来了工作人员,工作人员提了一大袋子,将现金放进去,然而在这期间,用户1 一定要出来,工作人员才能把钱放进去,也就是说,锁需要打开。而当工作人员将钱处理好之后,用户需要重新竞争这把锁,因为可能有存钱的、有查看存款的…
(3) 使用wait 方法,当满足一定的条件被唤醒时,重新尝试获取到这个锁。
public class Test { public static void main(String[] args) throws InterruptedException { Object object = new Object(); object.wait(); } }
输出结果:
当我们使用 wait 方法不加锁的时候,我们会发现编译器报异常,异常的英文为:非法监视状态。而 synchronized 也叫做监视器锁。
2. notify 方法
关于 notify 的使用:
(1) notify 方法也要使用 synchronized 关键字进行加锁操作。
(2) notify 方法实际上一次只唤醒一 个线程,当有多个线程都在等待中,调用 notify 方法就相当于随机唤醒了一个线程,而其他线程都保持原状。
(3) notify 方法这是通知对方被唤醒,但调用 notify 本身的线程并不是立即释放锁,而是要等待当前的 synchronized 代码块执行完才能释放锁。
程序清单12:
public class Test { static class WaitTask implements Runnable { private Object locker = null; public WaitTask (Object locker) { this.locker = locker; } @Override public void run() { synchronized (locker) { System.out.println(" wait 开始..."); try { locker.wait(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(" wait 结束!"); } } } static class NotifyTask implements Runnable { private Object locker = null; public NotifyTask (Object locker) { this.locker = locker; } @Override public void run() { synchronized (locker) { System.out.println(" notify 开始"); locker.notify(); System.out.println(" notify 结束"); } } } public static void main(String[] args) throws InterruptedException { //Object 对象的创建,就是为了能够方便地对线程进行加锁 / 通知操作 Object locker = new Object(); Thread t1 = new Thread (new WaitTask(locker)); Thread t2 = new Thread (new NotifyTask(locker)); t1.start(); Thread.sleep(3000); t2.start(); } }
输出结果:
程序清单13:
public class Test { static class WaitTask implements Runnable { private Object locker = null; public WaitTask (Object locker) { this.locker = locker; } @Override public void run() { synchronized (locker) { System.out.println(" wait 开始..."); try { locker.wait(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(" wait 结束!"); } } } static class NotifyTask implements Runnable { private Object locker = null; public NotifyTask (Object locker) { this.locker = locker; } @Override public void run() { synchronized (locker) { System.out.println(" notify 开始"); locker.notify(); System.out.println(" notify 结束"); } } } public static void main(String[] args) throws InterruptedException { Object locker = new Object(); Thread t1 = new Thread (new WaitTask(locker)); Thread t2 = new Thread (new WaitTask(locker)); Thread t3 = new Thread (new WaitTask(locker)); Thread t4 = new Thread (new WaitTask(locker)); Thread t5 = new Thread (new NotifyTask(locker)); t1.start(); t2.start(); t3.start(); t4.start(); Thread.sleep(3000); t5.start(); } }
输出结果:
在上图中,我们发现 4个 线程正在阻塞等待中,而只唤醒了 1个 线程,也就是说,t2,t3,t4 都在阻塞等待中,这也就说明了 notify 方法一次只能唤醒一个线程。
而 notifyAll 方法顾名思义,它的存在,就可以一次唤醒所有线程。
程序清单14:
public class Test { static class WaitTask implements Runnable { private Object locker = null; public WaitTask (Object locker) { this.locker = locker; } @Override public void run() { synchronized (locker) { System.out.println(" wait 开始..."); try { locker.wait(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(" wait 结束!"); } } } static class NotifyTask implements Runnable { private Object locker = null; public NotifyTask (Object locker) { this.locker = locker; } @Override public void run() { synchronized (locker) { System.out.println(" notifyAll 开始"); locker.notifyAll(); System.out.println(" notifyAll 结束"); } } } public static void main(String[] args) throws InterruptedException { Object locker = new Object(); Thread t1 = new Thread (new WaitTask(locker)); Thread t2 = new Thread (new WaitTask(locker)); Thread t3 = new Thread (new WaitTask(locker)); Thread t4 = new Thread (new WaitTask(locker)); Thread t5 = new Thread (new NotifyTask(locker)); t1.start(); t2.start(); t3.start(); t4.start(); Thread.sleep(3000); t5.start(); } }
输出结果:
3. notify 和 notifyAll 的区别
notify 是随机唤醒等待队列中的一个线程,其他线程还是乖乖等着。
notifyAll 是一下唤醒所有线程,但这些线程需要重新竞争锁。
十、wait 和 sleep 的区别和联系 ( 面试题 )
① sleep 操作是指定一个固定时间来阻塞等待,而 wait 既可以指定时间,也可以无限等待。
② sleep 唤醒通过时间到或 interrupt 唤醒 ,而 wait 唤醒也可以通过时间到或 interrupt 唤醒,但 wait 通常需要使用 notify 搭配。
③ wait 主要的用途就是为了协调线程之间的先后顺序,这样的场景并不适合使用 sleep,sleep 只是单纯让该线程休眠,其并不涉及到多个线程的配合。
④ wait 是 Object 类的方法,而 sleep 是 Thread 类的方法。
⑤ wait 执行了释放锁操作,而sleep 不释放锁。