共享存在的问题

由于现在的的操作系统大多是分时系统, 在[多线程]的环境下, 由于公共资源可能会被多个线程共享, 很容易导致数据错乱或发生数据安全问题, 即:数据有可能丢失, 有可能增加, 有可能错乱(数据一致性问题)。

问题演示:

两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

  1. static int counter = 0;
  2. public static void main(String[] args) throws InterruptedException {
  3. Thread t1 = new Thread(() -> {
  4. for (int i = 0; i < 5000; i++) {
  5. counter++;
  6. }
  7. }, "t1");
  8. Thread t2 = new Thread(() -> {
  9. for (int i = 0; i < 5000; i++) {
  10. counter--;
  11. }
  12. }, "t2");
  13. t1.start();
  14. t2.start();
  15. t1.join();
  16. t2.join();
  17. System.out.println(counter);
  18. log.debug("{}",counter);
  19. }

以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理 解,必须从字节码来进行分析
例如对于 i++ (自增或自减)而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:
image.png
而 Java 的内存模型告诉我们,要完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:
简单来说,一次自增,底层会做三个操作:获取 i 原来的值;将原来的值加一;将修改的值存入 i ;
如果是单线程这些代码是顺序执行(不会交错)没有问题。
但多线程环境下,比如两个线程的这 8 行代码就有可能会出现交错运行,产生错误的情况:比如 当线程二获取i的值并进行运算之后,还没有将值写入 i 之前线程一就去获取 i 的值 0 进行操作,等线程一操作完之后再将上下文切换到线程二,将之前的i=-1写入内存;所以最终的值是 -1;
image.png
另外一种情况是最终答案是正数;也是同样的道理;
其中上面的对静态变量 i 进行操作的代码就是临界区代码;
image.png
竞态条件 Race Condition: 多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

synchronized初识

为了避免上面例子里临界区的竞态条件的发生,我们可以有多种手段可以达到目的,比较常见的手段有如下几种:

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

这里使用阻塞式的解决方案:synchronized关键字来解决上述问题,即俗称的【对象锁】,它采用互斥的方式让同一 时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁 的线程可以安全的执行临界区内的代码,不用担心线程上下文的切换导致产生数据的不一致性;

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

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

用法:

  1. synchronized(对象) // 线程1先进, 线程2(blocked)
  2. {
  3. //临界区代码
  4. }

当线程一执行到synchronized代码块时,会获取到着这段代码的对象锁;此时当其他线程也执行到该代码块时由于对象锁已经被线程一拿到了,所以其他线程并不能执行该代码块中的内容进而被阻塞在这里,等到线程一执行完并释放对象锁之后,其他线程才可以拿到对象锁进而执行该代码块块内的临界区代码。
所以上面的代码加上synchronized关键字的解决代码如下:

  1. static int counter = 0;
  2. public static void main(String[] args) throws InterruptedException {
  3. static final Object room = new Object();
  4. Thread t1 = new Thread(() -> {
  5. for (int i = 0; i < 5000; i++) {
  6. synchronized(room){
  7. counter++;
  8. }
  9. }
  10. }, "t1");
  11. Thread t2 = new Thread(() -> {
  12. for (int i = 0; i < 5000; i++) {
  13. synchronized(room){
  14. counter--;
  15. }
  16. }
  17. }, "t2");
  18. t1.start();
  19. t2.start();
  20. t1.join();
  21. t2.join();
  22. System.out.println(counter);
  23. }
  • synchronized(锁对象) 中的锁对象,可以想象为一个房间(room),有唯一入口(门)房间只能一次进入一人 进行计算,线程 t1,t2 想象成两个人
  • 当线程 t1 执行到 synchronized(room) 时就好比 t1 进入了这个房间,并锁住了门拿走了钥匙,在门内执行 count++ 代码
  • 这时候如果 t2 也运行到了 synchronized(room) 时,它发现门被锁住了,只能在门外等待,发生了上下文切 换,阻塞住了
  • 这中间即使 t1 的 cpu 时间片不幸用完,被踢出了门外(不要错误理解为锁住了对象就能一直执行下去哦), 这时门还是锁住的,t1 仍拿着钥匙,t2 线程还在阻塞状态进不来,只有下次轮到 t1 自己再次获得时间片时才 能开门进入
  • 当 t1 执行完 synchronized{} 块内的代码,这时候才会从 obj 房间出来并解开门上的锁,唤醒 t2 线程把钥 匙给他。t2 线程这时才可以进入 obj 房间,锁住了门拿上钥匙,执行它的 count— 代码 。

synchronized思考

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

如果:

  • 如果把 synchronized(obj) 放在 for 循环的外面,如何理解?— 原子性

那么此时整个for循环都变成了一个原子操作,只有等这个线程的for循环执行完才会被下释放锁轮到一个线程执行。

  • 如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?— 锁对象不同

此时不能保证共享安全问题。两个不同的锁对象会让线程进入不同的(房间)代码块空间,自然不能保证线程的共享安全问题。应该使用同一个对象锁,才能保证。

  • 如果 t1 synchronized(obj) 而 t2 没有加会怎么样?如何理解?— 锁对象

t2没有使用synchronized关键字的话,等到t2线程执行到临界区代码的时候它就不会去获取对象锁,它不获取对象锁它就能执行临界区的代码,还是会造成共享安全问题。所以只要涉及到临界区的代码都应该锁上;

面向对象改进

区别于在每个线程都对临界区进行加锁操作,不如将涉及到临界区的代码封装进一个类中,在该类中封装对该临界区的操作,并给每一个操作都加上锁,这样更方便管理。
这样在使用的时候只需要创建一个对象,然后调用该对象的方法进行临界区操作即可。

比如下面所示:

  1. /**
  2. * 涉及到临界区的代码都封装进了一个类中;
  3. 这里使用this做为锁对象,也就是使用每一个new出来的对象作为临界区的锁对象,一旦使用不同的Room对象对同一个临界区代码进行上锁还是会存在线程安全问题;
  4. */
  5. class Room {
  6. int value = 0;
  7. public void increment() {
  8. synchronized (this) {
  9. value++;
  10. }
  11. }
  12. public void decrement() {
  13. synchronized (this) {
  14. value--;
  15. }
  16. }
  17. public int get() {
  18. synchronized (this) {
  19. return value;
  20. }
  21. }
  22. }

这样调用的时候方便多了,也体现了面向对象的思想,可重用性高;

  1. public static void main(String[] args) throws InterruptedException {
  2. Room room = new Room();
  3. Thread t1 = new Thread(() -> {
  4. for (int j = 0; j < 5000; j++) {
  5. room.increment();}
  6. }, "t1");
  7. Thread t2 = new Thread(() -> {
  8. for (int j = 0; j < 5000; j++) {
  9. room.decrement();
  10. }
  11. }, "t2");
  12. t1.start();
  13. t2.start();
  14. t1.join();
  15. t2.join();
  16. }

在方法上的 synchronized

synchronized 声明在普通方法上相当于将this作为对象锁,

  1. class Test{
  2. public synchronized void test() {
  3. }
  4. }
  5. //等价于
  6. class Test{
  7. public void test() {
  8. //this作为对象锁
  9. synchronized(this) {
  10. }
  11. }
  12. }

synchronized 声明在静态方法上将类作为锁对象

  1. class Test{
  2. public synchronized static void test() {
  3. }
  4. }
  5. //等价于
  6. class Test{
  7. public static void test() {
  8. synchronized(Test.class) {
  9. }
  10. }
  11. }

synchronized线程八锁

所谓的线程八锁其实考验的是synchronized关键字锁住的是哪个对象,以及能否实现互斥的效果;

猜猜下面的程序输出的情况或者是顺序是什么?

情况一: 12 或 21
  1. class Number{
  2. public synchronized void a() {
  3. System.out.println("1");
  4. }
  5. public synchronized void b() {
  6. System.out.println("2");
  7. }
  8. public static void main(String[] args) {
  9. Number n1 = new Number();
  10. new Thread(() -> {
  11. n1.a();
  12. }).start();
  13. new Thread(() -> {
  14. n1.b();
  15. }).start();
  16. }
  17. }

1、临界类中的synchronized关键字锁的都是成员方法,所以它的锁对象是this对象实例。
2、main方法中之创建了一个Number实例,所以这两个线程使用的锁对象都是同一个,能实现互斥的效果。
3、这种情况很简单,答案有可能是 1 2或者是2 1 ,关键取决于哪个线程先被调度获得执行权,先获取执行权的线程先输出;

情况二: 1s后12,或 2 1s后 1
  1. class Number{
  2. public synchronized void a() {
  3. //睡眠1秒钟
  4. MySleepUtil.mSleep(1);
  5. System.out.println("1");
  6. }
  7. public synchronized void b() {
  8. System.out.println("2");
  9. }
  10. public static void main(String[] args) {
  11. Number n1 = new Number();
  12. new Thread(()->{ n1.a(); }).start();
  13. new Thread(()->{ n1.b(); }).start();
  14. }
  15. }

1、跟情况1只是多了一个sleep方法,但是由于sleep方法并不会释放锁,所以另一个线程得等到当前线程执行完之后才能获取锁。

情况三: 3 1s 12 或 23 1s 1 或 32 1s 1
  1. class Number{
  2. public synchronized void a() {
  3. MySleepUtil.mSleep(1);
  4. System.out.println("1");
  5. }
  6. public synchronized void b() {
  7. System.out.println("2");
  8. }
  9. public void c() {
  10. System.out.println("3");
  11. }
  12. public static void main(String[] args) {
  13. Number n1 = new Number();
  14. new Thread(()->{ n1.a(); }).start();
  15. new Thread(()->{ n1.b(); }).start();
  16. new Thread(()->{ n1.c(); }).start();
  17. }
  18. }

1、多了一个没有synchronized关键字修饰的c方法。所以这个c方法被调用的时候就没有互斥效果。是并行执行的。
2、还是看哪个线程先被执行,但不管哪个线程先被执行,由于c方法是并行的,所以c方法都会紧随其后被执行。3、若果是a线程先执行,由于a方法睡了1秒,所以在它睡的时候由于c方法不用锁也能执行,所以在它睡觉的时候c方法就可以执行了,但是b方法得等a方法睡醒并执行完才轮到它。

情况四: 2 1s 后 1
  1. class Number{
  2. public synchronized void a() {
  3. MySleepUtil.mSleep(1);
  4. System.out.println("1");
  5. }
  6. public synchronized void b() {
  7. System.out.println("2");
  8. }
  9. public static void main(String[] args) {
  10. Number n1 = new Number();
  11. Number n2 = new Number();
  12. new Thread(()->{ n1.a(); }).start();
  13. new Thread(()->{ n2.b(); }).start();
  14. }
  15. }

1、这个情况有两个临界类对象,而且synchronized关键字锁的是成员方法,所以这两个锁对象不一样,当然就没有互斥的效果了。

情况5:2 1s 后 1
  1. class Number{
  2. //类对象锁
  3. public static synchronized void a() {
  4. MySleepUtil.mSleep(1);
  5. System.out.println("1");
  6. }
  7. //实例对象作为锁
  8. public synchronized void b() {
  9. System.out.println("2");
  10. }
  11. public static void main(String[] args) {
  12. Number n1 = new Number();
  13. new Thread(()->{ n1.a(); }).start();
  14. new Thread(()->{ n1.b(); }).start();
  15. }
  16. }

1、由于a方法的synchronized关键字锁的是静态方法,锁对象是类;b方法的synchronized关键字锁定的是成员方法,锁对象是实例对象;所以这两个锁不是同一个锁,自然没有互斥效果。
2、没有互斥效果就并发执行呗,由于a方法有个sleep方法,所以不管哪个线程先执行,都是先输出2再输出1。

情况6:1s 后12, 或 2 1s后 1
  1. class Number{
  2. public static synchronized void a() {
  3. MySleepUtil.mSleep(1);
  4. System.out.println("1");
  5. }
  6. public static synchronized void b() {
  7. System.out.println("2");
  8. }
  9. public static void main(String[] args) {
  10. Number n1 = new Number();
  11. new Thread(()->{ n1.a(); }).start();
  12. new Thread(()->{ n1.b(); }).start();
  13. }
  14. }

1、两个都是静态方法,所以这个锁对象就是同一个,有互斥效果。
2、有互斥效果就看哪个线程先被执行来决定

情况7:2 1s 后 1
  1. class Number{
  2. public static synchronized void a() {
  3. MySleepUtil.mSleep(1);
  4. System.out.println("1");
  5. }
  6. public synchronized void b() {
  7. System.out.println("2");
  8. }
  9. public static void main(String[] args) {
  10. Number n1 = new Number();
  11. Number n2 = new Number();
  12. new Thread(()->{ n1.a(); }).start();
  13. new Thread(()->{ n2.b(); }).start();
  14. }
  15. }

1、又是一个静态方法一个非静态方法,而且还创建了两个实例对象,又是没有互斥效果

情况八: 1s 后12, 或 2 1s后 1
  1. class Number{
  2. public static synchronized void a() {
  3. MySleepUtil.mSleep(1);
  4. System.out.println("1");
  5. }
  6. public static synchronized void b() {
  7. System.out.println("2");
  8. }
  9. public static void main(String[] args) {
  10. Number n1 = new Number();
  11. Number n2 = new Number();
  12. new Thread(()->{ n1.a(); }).start();
  13. new Thread(()->{ n2.b(); }).start();
  14. }
  15. }

1、虽然是两个实例对象,但由于锁的方法是静态方法,所以不管实例化多少个对象都是同一个锁,有互斥效果。

变量的线程安全分析

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

一个对象实例的成员变量也可能会有多个线程同时访问,所以也会产生线程共享问题。
所以如果它们没有共享,则线程安全 ;如果它们被共享了,根据它们的状态是否能够改变,又分两种情况

  • 如果只有读操作,则线程安全
  • 如果有读写操作,则这段代码是临界区,需要考虑线程安全

局部变量是否线程安全?

局部变量是线程安全的 ,但局部变量引用的对象(比如说堆中的对象)则未必 。 如果该对象没有逃离方法的作用访问,它是线程安全的 ;如果该对象逃离方法的作用范围(比如说被return出去了),就需要考虑线程安全了

局部变量线程安全分析

  1. public static void test1() {
  2. int i = 10;
  3. i++;
  4. }

每个线程调用 test1() 方法时局部变量 i,会在每个线程的栈帧内存中被创建多份
image.png
虽然这个i++操作在底层确实不是原子操作,但是它不会被多个线程共享自然不会存在共享问题,也不会产生线程安全问题。
而局部变量的引用稍有不同,这里我们 先看一个成员变量的例子

  1. class ThreadUnsafe {
  2. //该成员变量对象存储在堆中
  3. ArrayList<String> list = new ArrayList<>();
  4. public void method1(int loopNumber) {
  5. for (int i = 0; i < loopNumber; i++) {
  6. // { 临界区, 会产生竞态条件
  7. method2();
  8. method3();// } 临界区
  9. }
  10. }
  11. private void method2() {
  12. list.add("1");
  13. }
  14. private void method3() {
  15. list.remove(0);
  16. }
  17. static final int THREAD_NUMBER = 2;
  18. static final int LOOP_NUMBER = 200;
  19. public static void main(String[] args) {
  20. ThreadUnsafe test = new ThreadUnsafe();
  21. for (int i = 0; i < THREAD_NUMBER; i++) {
  22. new Thread(() -> {
  23. test.method1(LOOP_NUMBER);
  24. }, "Thread" + i).start();
  25. }
  26. }
  27. }

执行上面的程序会有其中一种情况是,如果线程2 还未 执行add操作,线程1 就先执行remove操作的话 就会报错:
image.png
因为这个集合对象是存在堆中的,无论哪个线程中的 method2 引用的都是同一个对象中的 list 成员变量, method3 与 method2 分析相同
image.png
如果 将 list 修改为局部变量 ,就不会产生空集合remove导致越界异常的问题。

  1. class ThreadSafe {
  2. public final void method1(int loopNumber) {
  3. ArrayList<String> list = new ArrayList<>();
  4. for (int i = 0; i < loopNumber; i++) {
  5. method2(list);
  6. method3(list);
  7. }
  8. }
  9. private void method2(ArrayList<String> list) {
  10. list.add("1");
  11. }
  12. private void method3(ArrayList<String> list) {
  13. list.remove(0);
  14. }
  15. static final int THREAD_NUMBER = 2;
  16. static final int LOOP_NUMBER = 200;
  17. public static void main(String[] args) {
  18. ThreadSafe test = new ThreadSafe();
  19. for (int i = 0; i < THREAD_NUMBER; i++) {
  20. new Thread(() -> {
  21. test.method1(LOOP_NUMBER);
  22. }, "Thread" + i).start();
  23. }
  24. }
  25. }

因为此时 list 是局部变量,每个线程调用时会创建其不同实例,没有共享 。而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象
image.png
方法访问修饰符带来的思考,如果把 method2 和 method3 的方法修改为 public 会不会代理线程安全问题?
分为两种情况:
情况1:有其它线程调用 method2 和 method3
这种情况也不会发生共享安全问题。毕竟不是线程间的共享资源。(每个线程调用方法时都会单独创建栈帧)
情况2:在 情况1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法,又开了个新线程去操作该对象,即

  1. class ThreadSafe {
  2. public final void method1(int loopNumber) {
  3. ArrayList<String> list = new ArrayList<>();
  4. for (int i = 0; i < loopNumber; i++) {
  5. method2(list);
  6. method3(list);
  7. }
  8. }
  9. public void method2(ArrayList<String> list) {
  10. list.add("1"); }
  11. public void method3(ArrayList<String> list) {
  12. list.remove(0);
  13. }
  14. }
  15. class ThreadSafeSubClass extends ThreadSafe{
  16. @Override
  17. public void method3(ArrayList<String> list) {
  18. new Thread(() -> {
  19. list.remove(0);
  20. }).start();
  21. }
  22. }

这个时候调用方法3的时候其内部又有一个新的线程跟原来的线程产生共享资源关系,就会存在安全问题。
所以开闭原则的好处就体现出来了,private、final关键字限制了子类不能覆盖private关键字修饰的方法,从而避免了上面的线程安全问题。

常见线程安全类
  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent 包下的类

这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。比如

  1. Hashtable table = new Hashtable();
  2. new Thread(()->{
  3. table.put("key", "value1");
  4. }).start();
  5. new Thread(()->{
  6. table.put("key", "value2");
  7. }).start();

虽然它们的每个方法是原子的 ,但注意它们多个不同方法的组合不是原子的,仍然会产生线程安全问题,所以说并不是线程安全类就不会产生线程安全问题,关键是使用的方法对了,才能保证线程的安全;关于具体的例子下面进行分析;
线程安全类方法的组合
分析下面代码是否线程安全?
image.png
这俩个get、put方法单独拿出来使用都是线程安全的方法,但是他们这些线程安全的方法组合起来就不一定是线程安全的了。在这几个方法中间还是会可能发生上下文切换,进而发生共享问题。
image.png

不可变类线程安全性

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

变量安全分析实例

例子一
  1. /**
  2. * 1、servlet是运行在tomcat实例下的,只有一个对象实例,所以会被tomcat下的多个线程共享使用,所以里面的成员变量都会存在共享问题。
  3. */
  4. class MyServlet extends HttpServlet {
  5. // 是否安全?这里的HashMap并不是线程安全的。
  6. Map<String,Object> map = new HashMap<>();
  7. // 是否安全?字符串是不可变量是线程安全的。
  8. String S1 = "...";
  9. // 是否安全?安全
  10. final String S2 = "...";
  11. // 是否安全?不是
  12. Date D1 = new Date();
  13. // 是否安全?不能保证安全。
  14. //跟字符串类的区别是日期类里面的属性状态是可以改变的。而字符串内部的属性状态不可改变。
  15. final Date D2 = new Date();
  16. public void doGet(HttpServletRequest request, HttpServletResponse response) {
  17. // 使用上述变量
  18. }
  19. }

例子二

典型应用,servlet调用service

  1. public class MyServlet extends HttpServlet {
  2. // 是否安全?servlet在tomcat中只有一份,而它里面的service是成员变量会被多个线程共享,所以service里面的count就不安全了。
  3. private UserService userService = new UserServiceImpl();
  4. public void doGet(HttpServletRequest request, HttpServletResponse response) {
  5. userService.update(...);
  6. }
  7. }
  8. public class UserServiceImpl implements UserService {
  9. // 记录调用次数
  10. private int count = 0;
  11. public void update() {
  12. // ...
  13. count++;
  14. }
  15. }

例子三
  1. @Aspect
  2. @Component
  3. public class MyAspect {
  4. /*是否安全?
  5. 1、MyAspect对象被spring进行管理,而spring中管理的对象默认都是单例的,那么它里面的成员变量也是会被共享的。
  6. 2、那就是不安全的,存在线程安全问题。
  7. 3、可以用环绕通知将其变为局部变量。
  8. 4、使该对象变成多例的也不行,因为进入前置通知时跟进入后置通知的时候两个对象可能不一样,时间就不能进行统计了。(环绕通知最好)
  9. */
  10. private long start = 0L;
  11. @Before("execution(* *(..))")
  12. public void before() {
  13. start = System.nanoTime();
  14. }
  15. @After("execution(* *(..))")
  16. public void after() {
  17. long end = System.nanoTime();
  18. System.out.println("cost time:" + (end-start));
  19. }
  20. }

例子四

将代码从低向上分析一下可能存在的安全问题:

先分析底层的dao —>UserService—>MyServlet

  1. public class MyServlet extends HttpServlet {
  2. /*
  3. 1、同样这个也是安全的,因为虽然它是成员变量,但是它里面的成员变量是私有的,没有其他地方去修改它的状态,也相对于不可变的。
  4. */
  5. private UserService userService = new UserServiceImpl();
  6. public void doGet(HttpServletRequest request, HttpServletResponse response) {
  7. userService.update(...);
  8. }
  9. }
  10. public class UserServiceImpl implements UserService {
  11. /*是否安全?
  12. 1、虽然这个dao实例是service的成员变量,但是由于dao其内部没有成员变量,所以也就不能被修改属性状态,自然不会存在线程安全问题;
  13. 2、跟不可变类有异曲同工之妙,都是不能修改内部属性状态。
  14. */
  15. private UserDao userDao = new UserDaoImpl();
  16. public void update() {
  17. userDao.update();
  18. }
  19. }
  20. public class UserDaoImpl implements UserDao {
  21. public void update() {
  22. String sql = "update user set password = ? where username = ?";
  23. /*
  24. 是否安全?
  25. 1、不同线程创建的是不同的连接实例,当然不存在共享问题。
  26. 2、dao中不存在成员变量,也就是说在多线程共享的时候不能去修改dao内部的属性,是线程安全的。
  27. */
  28. try (Connection conn = DriverManager.getConnection("","","")){
  29. // ...
  30. } catch (Exception e) {
  31. // ...
  32. }
  33. }
  34. }

例子五

同样从低层开始分析

  1. public class MyServlet extends HttpServlet {
  2. // 是否安全
  3. private UserService userService = new UserServiceImpl();
  4. public void doGet(HttpServletRequest request, HttpServletResponse response) {
  5. userService.update(...);
  6. }
  7. }
  8. public class UserServiceImpl implements UserService {
  9. // 是否安全
  10. private UserDao userDao = new UserDaoImpl();
  11. public void update() {
  12. userDao.update();
  13. }
  14. }
  15. public class UserDaoImpl implements UserDao {
  16. /*是否安全?
  17. 1、这个连接实例并不是方法内的局部变量,而是dao的成员变量,这样就因为servlet只有一份,service只有一份,dao也只有一份,所以这个连接实例肯定会被多个线程共享的。
  18. 比如线程一刚创建好一个连接对象,线程二就将其close关闭掉了,这就产生线程安全问题了。
  19. */
  20. private Connection conn = null;
  21. public void update() throws SQLException {
  22. String sql = "update user set password = ? where username = ?";
  23. conn = DriverManager.getConnection("","","");
  24. // ...
  25. conn.close();
  26. }
  27. }

例子六
  1. public class MyServlet extends HttpServlet {
  2. // 是否安全
  3. private UserService userService = new UserServiceImpl();
  4. public void doGet(HttpServletRequest request, HttpServletResponse response) {
  5. userService.update(...);
  6. }
  7. }
  8. public class UserServiceImpl implements UserService {
  9. public void update() {
  10. /*
  11. 因为这个dao在service中是作为方法的局部变量存在的,所以会导致每个线程都会创建一个新的连接对象。但是不推荐这样写。
  12. */
  13. UserDao userDao = new UserDaoImpl();
  14. userDao.update();
  15. }
  16. }
  17. public class UserDaoImpl implements UserDao {
  18. // 是否安全
  19. private Connection = null;
  20. public void update() throws SQLException {
  21. String sql = "update user set password = ? where username = ?";
  22. conn = DriverManager.getConnection("","","");
  23. // ...
  24. conn.close();
  25. }
  26. }

例子七
  1. public abstract class Test {
  2. public void bar() {
  3. /*
  4. 1、虽然这个对象是一个局部变量,但是还是得看看它有没有将引用暴露给其他线程。
  5. 2、比如这个例子的类在设计的时候有一个抽象方法,所以就有机会将这个局部变量的对象传递给抽象方法。所以该抽象类的子类就有可能对这个局部变量做一些不恰当的事情(行为不确定)。
  6. */
  7. SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
  8. foo(sdf);
  9. }
  10. //将成员变量暴露给其实现类
  11. public abstract foo(SimpleDateFormat sdf);
  12. public static void main(String[] args) {
  13. new Test().bar();
  14. }
  15. }

其中 foo 的行为是不确定的,可能导致不安全的发生,被称之为外星方法

  1. public void foo(SimpleDateFormat sdf) {
  2. String dateStr = "1999-10-11 00:00:00";
  3. for (int i = 0; i < 20; i++) {
  4. new Thread(() -> {
  5. try {
  6. sdf.parse(dateStr);
  7. } catch (ParseException e) {
  8. e.printStackTrace();
  9. }
  10. }).start();
  11. }
  12. }

比较 JDK 中 String 类的实现 ?
String 类的实现是让其不可被重写,防止其中的方法被覆盖引起安全隐患。

练习题

Synchronized原理 之Monitor

Java对象头

HotSpot[虚拟机] 中,Java对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

以 32 位虚拟机为例,普通对象的对象头结构如下
image.png

对象头在32位虚拟机下是64位,即8个字节;其中的四个字节是 Mark Word 另外4个字节是Klass Word,其中的Klass Word为 指向类的指针 ,指向该对象对应的Class,表明这个对象所属的类型;

Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。

Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的:

image.png

图中可以看出 其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。

(以32位虚拟机为例)

image.png

Mark Word 有32位即4个字节,其中Normal状态下的信息如下
image.png

25位hashcode 表示每个对象都有的哈希码,4位 age 表示垃圾回收的分代年龄,其中1位表示偏向锁,还有两位表示加锁的状态( **无锁(001)、偏向锁(101)、轻量级锁(00)、重量级锁(10)** )。

注意:数组对象的对象头还包含了数组的长度信息
image.png

所以实际上一个对象,它的对象头占用了不少的空间;比如说Integer对象(12字节)在内存中比普通类型int(4字节)多占8个字节。这是在32位虚拟机的情况下(8字节是对象头的,4字节是存储int的value的)。这也是在内存敏感的场景下使用int类型而不是Integer类型的原因(太大了占内存)。

Synchronized底层实现-重量级锁(没讲轻量级锁之前的流程)

Monitor被翻译为监视器或者说管程 。 每个java对象都可以关联一个Monitor,如果使用synchronized给对象上锁(重量级),该对象头的Mark Word中就被设置为指向Monitor对象的指针 ,进行相互关联。
Synchronized是怎么上锁的呢?
比如现在有一个线程2在执行Synchronized同步代码块,执行的时候就会去尝试去获取obj锁对象,将这个对象与操作系统提供的Monitor对象相关联,将obj对象的MarkWord指针指向了Monitor对象,相当于在obj对象中记录了这个Monitor对象的地址,将来可以根据这个地址找到Monitor对象。

由下表Mark Word结构可以知道,当你这个对象没有获取到锁的时候表示锁状态的两个字节是01,也就是没有与Monitor对象进行关联;当你获取到了锁并关联到了一个Monitor对象的时候表示锁状态的这两个字节就变成了10 。
image.png
然后状态变了,结构也变了image.png
这样一来线程2拿到了对象锁,那么它也就变成了这个Monitor对象的Owner。
此时若果又来了一个线程1,它首先会去尝试能否获取到对象锁obj,也就是看obj锁它有没有关联到Monitor对象,结果发现因为线程2捷足先登了,就是说obj它已经关联到一个Monitor对象,然后它又去看这个Monitor对象有没有Owner,发现这个Monitor对象的Owner是线程2,所以线程1它就进不去同步代码块。
虽然这个线程1它也会去关联这个Monitor对象,但是是通过里面的EntryList(可以理解为等待队列或者阻塞队列)关联而不是Owner。这样线程1就进入了BLOCKED状态。此时线程3来的话也跟线程1一样被放进了EntryList。
等到线程2执行完了临界区代码,释放Monitor对象的Owner后去通知(谁通知?)的所有权阻塞队列中的线程才将他们叫醒,然后它们去竞争成为Monitor对象的下一任主人。
image.png
这个Monitor对象其实就是充当了锁的角色,我们选中的obj对象总是跟Monitor对象相关联,且只要是同一个obj对象都会跟同一个Monitor对象相关联。
小结:

  • 当Thread1访问到synchronized(obj)中的共享资源的时候,首先会将synchronized中的锁对象中对象头的MarkWord去尝试指向操作系统的Monitor对象. 让锁对象中的MarkWord和Monitor对象相关联. 如果关联成功, 将obj对象头中的MarkWord的对象状态从01改为10。
  • 因为Monitor没有和其他的obj的MarkWord相关联, 所以Thread1就成为了该Monitor的Owner(所有者)。
  • 若果此时又来了个Thread1执行synchronized(obj)代码, 它首先会看看能不能执行该临界区的代码; 它会检查obj是否关联了Montior, 此时已经有关联了, 它就会去看看该Montior有没有所有者(Owner), 发现有所有者了(Thread2); Thread1也会和该Monitor关联, 该线程就会进入到它的EntryList(阻塞队列);
  • 当Thread2执行完临界区代码后, Monitor的Owner(所有者)就空出来了. 此时就会通知Monitor中的EntryList阻塞队列中的线程, 这些线程通过竞争, 成为新的所有者。

注意:

  • synchronized 必须是进入同一个锁对象的monitor 才有上述的效果; —> 也就要使用同一把锁
  • 不加 synchronized的锁对象不会关联监视器,不遵从以上规则。

synchronized原理进阶之加锁流程(重点)

(用于优化Monitor这类的重量级锁)

轻量级锁

  • 轻量级锁的使用场景: 如果一个对象虽然有多个线程要对它进行加锁,但是加锁的时间是错开的(也就是没有人可以竞争的),那么可以使用轻量级锁来进行优化。轻量级锁对使用者是透明的,即语法仍然是synchronized,假设有两个方法同步块,可以利用同一个对象加锁
  • eg: 简单来说,线程A来操作临界区的资源, 给资源加锁,到执行完临界区代码,释放锁的过程, 没有线程来竞争, 此时就可以使用轻量级锁; 如果这期间有线程来竞争的话, 就会升级为重量级锁(synchronized)

轻量级锁加锁流程

如下面的这个代码,将来当前线程在执行method1的时候又调用method2,进行两次加锁的动作,让我们来简单分析一下它的加锁流程

  1. static final Object obj = new Object();
  2. public static void method1() {
  3. synchronized( obj ) {
  4. // 同步块 A
  5. method2();
  6. }
  7. }
  8. public static void method2() {
  9. synchronized( obj ) {
  10. // 同步块 B
  11. }
  12. }

1、一个方法对应线程中的一个栈帧; 每次执行到synchronized代码块时,都会在栈帧中创建锁记录(Lock Record)对象(该对象是jvm层面的),每个线程都会包括一个锁记录的结构对象,锁记录对象内部可以储存加锁对象的Mark Word和对象引用reference 。如下图所示,
image.png
2、 创建完锁记录对象后让锁记录中的Object reference指向被锁住的对象,并且尝试进行CAS(compare and sweep)替换Object锁对象的Mark Word ,将Mark Word 的值存入锁记录对象中 。(交换的目的是为了表示加锁)
image.png
3、 如果cas替换成功,那么锁对象的对象头储存的就是锁记录lock record的地址和状态00,而锁记录里面就是哈希码、分代年龄、锁状态01,方便之后锁恢复的时候交换回来;如下所示 。状态00表示轻量级锁。

image.png

4、如果cas替换失败,有两种情况

  1. 如果是其它线程已经持有了该Object的轻量级锁,那么表示有竞争,将进入锁膨胀阶段,该阶段在下面有介绍
  • 此时对象Object对象头中已经存储了别的线程的锁记录地址且锁状态是00,也就是指向了其他线程;
  1. 如果是自己的线程已经执行了synchronized进行加锁,那么就会再添加一条 Lock Record 作为重入的计数( 线程多次加锁, 锁重入 )
  • 上面代码中,临界区中又调用了method2, method2中又进行了一次synchronized加锁操作, 此时就会在虚拟机栈中再开辟一个method2方法对应的栈帧(栈顶), 该栈帧中又会存在一个独立的Lock Record, 此时它发现对象的对象头中指向的就是自己线程中栈帧的锁记录; 加锁也就失败了, 这种现象就叫做锁重入;
  • 线程中有多少个锁记录, 就能表明该线程对这个对象加了几次锁 (锁重入计数)

image.png

轻量级锁解锁流程

5、 当线程退出synchronized代码块的时候,如果获取的是取值为 null 的锁记录 ,表示有锁重入,这时重置锁记录,表示重入计数减一
image.png
6、当线程退出synchronized代码块的时候,如果获取的锁记录取值不为 null,那么就会使用cas将Mark Word的值恢复给对象, 也就是将之前替换的内容还原。
image.png

  • 替换成功则解锁成功 (轻量级锁解锁成功) 状态变回01
  • 替换失败,表示有竞争, 则说明有其他轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程 (Monitor流程)

锁膨胀阶段

如果在尝试加轻量级锁的过程中,cas替换mark word操作无法成功,这里有一种情况就是其它线程已经为这个对象加上了轻量级锁,这是就要进行锁膨胀(有竞争),将轻量级锁变成重量级锁。
image.png
1、 当 Thread-1 尝试进行轻量级加锁时,此前Thread-0 早就已经对该对象加了轻量级锁, 此时就会发生锁膨胀 。
image.png
因为thread0已经跟锁对象进行了cas替换,将锁对象的状态改为00;这个时候thread1再想进行cas交换就失败了,然后就会进入锁膨胀流程。

2、Thread-1线程 进入锁膨胀流程 ,让接下来的解锁操作进入重量级锁的解锁过程

  • 因为**Thread-1**线程加轻量级锁失败, 轻量级锁没有阻塞队列的概念, 所以此时就要为object锁对象申请Monitor锁(重量级锁),让Object锁对象的mark word指向重量级锁地址,将锁状态改为10状态,然后线程-1自己进入Monitor 的EntryList 变成BLOCKED状态 进行等待

image.png

重量级锁的解锁过程

3、当Thread-0 线程执行完synchronized同步块时,此时它拿的还是轻量级锁,解锁的时候会尝试使用cas将Mark Word的值恢复给对象头, 肯定恢复失败,因为此时对象的对象头中存储的是重量级锁的地址,状态变为10了(之前的是00, 肯定恢复失败)。
那么会进入重量级锁的解锁过程,即按照Monitor的地址找到Monitor对象,将Owner设置为null,唤醒EntryList中的Thread-1线程(跟之前讲的差不多) 。

自旋优化

当发生重量级锁竞争的时候,我们可以使用自旋来进行优化 (即暂时不加入Monitor的阻塞队列EntryList中,而是进行几次循环之后再做打算)。
如果当前线程自旋成功(即在自旋的时候持锁的线程释放了锁),那么当前线程就不用进行阻塞了,也就可以不用进行上下文切换(持锁线程执行完synchronized同步块后,释锁,Owner为空,唤醒阻塞队列来竞争,胜出的线程得到cpu执行权的过程) 就获得了锁 。
自旋成功的情况(时间线自上而下)
image.png
自旋重试失败的情况,自旋了一定次数还是没有等到持锁的线程释放锁
image.png
注意:
自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
Java 7 之后不能控制是否开启自旋功能 。

偏向锁优化

在轻量级的锁中,我们发现如果同一个线程对同一个对象进行重入锁时,也需要执行CAS操作,这是有点耗时。那么java6开始引入了偏向锁的,即只有第一次使用CAS时将对象的Mark Word头设置为入锁线程ID之后这个入锁线程再进行重入锁时,**发现线程ID是自己的,那么就不用再进行CAS了** 。并且只要以后不发生竞争,这个锁对象就会偏向该线程,归该线程所有;

  • 升级为轻量级锁的情况 (会进行偏向锁撤销) : 获取偏向锁的时候, 发现线程ID不是自己的, 此时通过CAS替换操作, 操作成功了, 此时该线程就获得了锁对象。( 此时是交替访问临界区, 撤销偏向锁, 升级为轻量级锁)
  • 升级为重量级锁的情况 (会进行偏向锁撤销) : 获取偏向锁的时候, 发现线程ID不是自己的, 此时通过CAS替换操作, 操作失败了, 此时说明发生了锁竞争。( 此时是多线程访问临界区, 撤销偏向锁, 升级为重量级锁)

锁升级流程小结(重点)

**JDK1.6以后的版本在处理同步锁时存在锁升级的概念,JVM对于同步锁的处理是从偏向锁开始的,随着竞争越来越激烈,处理方式从偏向锁升级到轻量级锁,最终升级到重量级锁。
JVM一般是这样使用锁和Mark Word的:
1,当对象没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。
2,当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。
3,当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向锁状态,Mark Word中记录的线程id也是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。 则无需使用CAS来加锁、解锁。
4,当另一个线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,且Mark Word中的线程id记录的不是B,那么线程B会先检查该线程是否存在(偏向锁不会主动释放锁),然后通过CAS操作替换线程 lD来试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行下一步步骤5。
5,偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁,。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。
上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。
6,轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。
7,自旋锁重试之后如果抢锁依然失败,即如果自旋次数到了该线程还没有释放锁,或者该线程还在执行,线程还在自旋等待,这时又有另一个一个线程过来竞争这个锁对象,,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞,即重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。

偏向状态演示

对象头格式以及对应的状态表示(64位)
image.png

  • Normal:一般状态,没有加任何锁,前面62位保存的是对象的信息,最后2位为状态(01),倒数第三位表示是否使用偏向锁(未使用:0)
  • Biased:偏向状态,使用偏向锁,前面54位保存的当前线程的ID,最后2位为状态(01),倒数第三位表示是否使用偏向锁(使用:1)
  • Lightweight:使用轻量级锁,前62位保存的是锁记录的指针,最后2位为状态(00)
  • Heavyweight:使用重量级锁,前62位保存的是Monitor的地址指针,最后2位为状态(10)

一个对象的创建过程

如果开启了偏向锁(jdk1.7后默认是开启的),

  • 那么对象刚创建之后,Mark Word 最后三位的值101,并且它的ThreadId,epoch,age(年龄计数器)都是0,在加锁的时候进行设置这些的值.
  • 注意 : 处于偏向锁的对象解锁后线程id仍存储于对象头中(一般不会主动释放线程id,这也是取偏向锁名字的原因之一)

如果没有开偏向锁

  • 如果没有开启偏向锁,那么对象创建后最后三位的值为001,这时候它的hashcode,age都为0,hashcode是第一次用到hashcode时才赋值的。

演示:引入jol依赖可以查看Java对象内存的状态信息

偏向锁:没有加锁时:

image.png
结果输出
image.png
为什么是001呢?因为偏向锁默认是延迟的,不会在程序启动的时候立刻生效,可以添加虚拟机参数来禁用延迟:-XX:BiasedLockingStartupDelay=0来禁用延迟。也可以让其睡眠几秒钟,然后再打印出来
image.png
结果如下,偏向锁开启之后就是101了。(不过还是推荐使用修改参数的方式进行测试)
image.png

加锁后(添加禁用延迟参数基础上)

image.png
输出结果如下:
需要注意的是这个线程id跟Java中调用方法返回的id是不一样的,这里的id是操作系统生成的id;image.png
.

测试禁用偏向锁:

如果没有开启偏向锁,那么对象创建后,最后三位的值为001,这时候它的hashcode,age都为0,hashcode是第一次用到hashcode时才赋值的。

在上面测试代码运行时在添加 VM 参数-XX:-UseBiasedLocking禁用偏向锁(禁用偏向锁则优先使用轻量级锁),退出synchronized状态变回 001

  • 最开始状态为001,然后加轻量级锁变成00,最后恢复成001

image.png

撤销偏向锁:测试hashcode方法

不禁用偏向锁,且禁用偏向锁延迟的基础上
image.png
注意, 当调用对象的hashcode方法的时候就会撤销这个对象的偏向锁,因为使用偏向锁时没有位置存hashcode的值了 ,所以会撤销偏向锁来位哈希码腾地方。简单来说就是会撤销掉该对象的偏向锁
image.png

撤销偏向锁之二

还有一种情况会使偏向锁升级位轻量级锁,那就是当线程一获取到了锁之后,本来锁对象是偏向于线程一的,结果此时又来了另一个线程二来尝试获取锁,这个时候偏向锁就会升级为轻量级锁。
偏向锁、轻量级锁的使用条件, 都是在于多个线程没有对同一个对象进行锁竞争的前提下, 如果有锁竞争,此时就使用重量级锁。
下面我们来演示一下在线程持有过锁并将同步代码块执行完毕后,线程id留在锁对象中,另一个线程尝试获取锁的过程(此时没有锁竞争)
image.png
image.png

wait和notify原理

概述

**线程0**获得到了**锁**, 成为**Monitor****Owner**, 但是它发现自己想要执行**synchroized代码块**的条件没有完全满足; 此时为了避免资源浪费它就可以调用**obj.wait**方法, 进入到Monitor中的**WaitSet**集合, 此时**线程0**的状态就变为**WAITING**

image.png

  • 处于BLOCKEDWAITING状态的线程都为阻塞状态,CPU都不会分给他们时间片,但是也有差别。
    • BLOCKED状态的线程是在竞争锁对象还没获得锁对象时,发现Monitor的Owner已经是别的线程了,此时就会进入EntryList中,并处于BLOCKED状态
    • WAITING状态的线程是获得了对象的锁,但是自身的原因无法执行synchroized的临界区资源需要进入阻塞状态时,锁对象调用了wait方法而进入了WaitSet中,处于WAITING状态 。
  • **处于BLOCKED状态的线程会在锁被释放的时候被唤醒**
  • 处于WAITING状态的线程只有被锁对象调用了notify方法(obj.notify/obj.notifyAll),才会被唤醒。 **然后它会进入到EntryList**, 重新竞争锁(此时就将锁升级为重量级锁)

相关API介绍

这几个方法 都是**线程之间进行协作的手段** **,是Object**中的方法; 必须在获取到锁后通过**锁对象**来调用,即只有当对象被锁以后(成为Owner),才能调用wait和notify方法

  • wait(): 让获得对象锁的线程到waitSet中一直等待
    • wait(long n) : 当该等待线程没有被notify, 等待时间到了之后, 也会自动唤醒。
  • notify(): 让获得对象锁的线程, 使用锁对象调用notify去waitSet的等待线程中挑一个唤醒
  • notifyAll() : 让获得对象锁的线程, 使用锁对象调用notifyAll去唤醒waitSet中所有的等待线程

Sleep和 Wait的区别

1、sleep是Thread方法,而wait是Object的方法

2、sleep不需要强制和synchronized配合使用,但wait需要和synchronized一起用。

3、sleep在睡眠的同时,不会释放对象锁的,但wait在等待的时候会释放对象锁。

4、Sleep(long n) 和 Wait(long n)的区别 (重点)

相同点

  • 阻塞状态都为TIMED_WAITING (限时等待)

wait/notify的正确使用

1、一开始的情况下在小南线程没有获取系统资源时,调用的是sleep方法进行睡眠等待系统资源,sleep方法并不会释放锁,这样的话由于小南拿着锁对象又干不了活,严重影响了其他线程的效率。

而且在sleep睡眠过程中就算系统资源准备好了(烟到了),小南线程也要睡够固定的时间才会醒来。(这个问题倒是其次,可以打断睡眠即可)

还有一个就是 送烟的线程也加上synchronized(room)的话,就好比小南在里面反锁了门睡觉,烟根本没法送进门,main没加synchronized就好像main线程是翻窗户进来的。

  1. @Slf4j(topic = "guizy.WaitNotifyTest")
  2. public class WaitNotifyTest {
  3. static final Object room = new Object();
  4. static boolean hasCigarette = false;
  5. static boolean hasTakeout = false;
  6. public static void main(String[] args) {
  7. //思考下面的解决方案好不好,为什么?
  8. new Thread(() -> {
  9. synchronized (room) {
  10. log.debug("有烟没?[{}]", hasCigarette);
  11. if (!hasCigarette) {
  12. log.debug("没烟,先歇会!");
  13. Sleeper.sleep(2); // 会阻塞2s, 不会释放锁
  14. }
  15. log.debug("有烟没?[{}]", hasCigarette);
  16. if (hasCigarette) {
  17. log.debug("可以开始干活了");
  18. }
  19. }
  20. }, "小南").start();
  21. for (int i = 0; i < 5; i++) {
  22. new Thread(() -> {
  23. synchronized (room) {
  24. log.debug("可以开始干活了");
  25. }
  26. }, "其它人").start();
  27. }
  28. Sleeper.sleep(1);
  29. new Thread(() -> {
  30. // 此时没有加锁, 所以会优先于其他人先执行
  31. // 这里能不能加 synchronized (room)?
  32. //synchronized (room) { // 如果加锁的话, 送烟人也需要等待小南睡2s的时间,此时即使送到了,小南线程也将锁释放了..
  33. hasCigarette = true;
  34. log.debug("烟到了噢!");
  35. //}
  36. }, "送烟的").start();
  37. }
  38. }

(占着茅坑不拉屎)
image.png

2、基于以上存在的问题,我们可以考虑使用wait - notify 机制。

  1. @Slf4j(topic = "guizy.WaitNotifyTest")
  2. public class WaitNotifyTest {
  3. static final Object room = new Object();
  4. static boolean hasCigarette = false;
  5. static boolean hasTakeout = false;
  6. public static void main(String[] args) {
  7. new Thread(() -> {
  8. synchronized (room) {
  9. log.debug("有烟没?[{}]", hasCigarette);
  10. if (!hasCigarette) {
  11. log.debug("没烟,先歇会!");
  12. try {
  13. room.wait(); // 此时进入到waitset等待集合, 同时会释放锁
  14. } catch (InterruptedException e) {
  15. e.printStackTrace();
  16. }
  17. }
  18. log.debug("有烟没?[{}]", hasCigarette);
  19. if (hasCigarette) {
  20. log.debug("可以开始干活了");
  21. }
  22. }
  23. }, "小南").start();
  24. for (int i = 0; i < 5; i++) {
  25. new Thread(() -> {
  26. // 小南进入等待状态了, 其他线程就可以获得锁了
  27. synchronized (room) {
  28. log.debug("可以开始干活了");
  29. }
  30. }, "其它人").start();
  31. }
  32. Sleeper.sleep(1);
  33. new Thread(() -> {
  34. synchronized (room) {
  35. hasCigarette = true;
  36. log.debug("烟到了噢!");
  37. //烟到了叫醒它
  38. room.notify();
  39. }
  40. }, "送烟的").start();
  41. }
  42. }

image.png
这里使用的是wait方法去睡眠,跟sleep不同的是wait方法会将当前线程占有的锁释放掉,这样其他线程也能持有该对象锁。这样就解决了其他线程阻塞的问题,但是 如果此时除了小南线程在等待唤醒, 还有一个线程也在等待唤醒呢? 此时的notify方法会唤醒谁呢?
3、比如现在有一个线程需要外卖(另一种资源),在调用唤醒方法的时候却唤醒了小南(小南要的是烟),这就出现问题了 。

  1. @Slf4j(topic = "guizy.WaitNotifyTest")
  2. public class WaitNotifyTest {
  3. static final Object room = new Object();
  4. static boolean hasCigarette = false;
  5. static boolean hasTakeout = false;
  6. public static void main(String[] args) {
  7. new Thread(() -> {
  8. synchronized (room) {
  9. log.debug("有烟没?[{}]", hasCigarette);
  10. if (!hasCigarette) {
  11. log.debug("没烟,先歇会!");
  12. try {
  13. room.wait(); // 此时进入到waitset等待集合, 同时会释放锁
  14. } catch (InterruptedException e) {
  15. e.printStackTrace();
  16. }
  17. }
  18. log.debug("有烟没?[{}]", hasCigarette);
  19. if (hasCigarette) {
  20. log.debug("可以开始干活了");
  21. }
  22. }
  23. }, "小南").start();
  24. new Thread(() -> {
  25. synchronized (room) {
  26. log.debug("外卖送到没?[{}]", hasTakeout);
  27. if (!hasTakeout) {
  28. log.debug("没外卖,先歇会!");
  29. try {
  30. room.wait();
  31. } catch (InterruptedException e) {
  32. e.printStackTrace();
  33. }
  34. }
  35. log.debug("外卖送到没?[{}]", hasTakeout);
  36. if (hasTakeout) {
  37. log.debug("可以开始干活了");
  38. } else {
  39. log.debug("没干成活...");
  40. }
  41. }
  42. }, "小女").start();
  43. Sleeper.sleep(1);
  44. new Thread(() -> {
  45. synchronized (room) {
  46. hasTakeout = true;
  47. log.debug("外卖到了噢!");
  48. room.notify();
  49. }
  50. }, "送外卖的").start();
  51. }
  52. }

notify只能随机唤醒一个WaitSet中的线程,这时如果有其它线程也在等待,那么就可能唤醒不了正确的线程,这种情况就被称之为【虚假唤醒】
为了解决这虚假唤醒的问题,我们可以将notify方法缓存notifyAll方法将全部正在等待唤醒的线程叫醒。

  1. new Thread(() -> {
  2. synchronized (room) {
  3. hasTakeout = true;
  4. log.debug("外卖到了噢!");
  5. room.notifyAll();
  6. }
  7. }, "送外卖的").start();

用notifyAll仅解决某个线程的唤醒问题,但使用if + wait判断仅有一次机会,一旦条件不成立,就没有重新判断的机会了。
所以我们的解决方法是用while + wait,当条件不成立,再次 wait等待。

  1. @Slf4j(topic = "guizy.WaitNotifyTest")
  2. public class Main {
  3. static final Object room = new Object();
  4. static boolean hasCigarette = false;
  5. static boolean hasTakeout = false;
  6. public static void main(String[] args) {
  7. new Thread(() -> {
  8. synchronized (room) {
  9. log.debug("有烟没?[{}]", hasCigarette);
  10. //while + wait,当条件不成立,再次 wait等待。
  11. while (!hasCigarette) {
  12. log.debug("没烟,先歇会!");
  13. try {
  14. room.wait(); // 此时进入到waitset等待集合, 同时会释放锁
  15. } catch (InterruptedException e) {
  16. e.printStackTrace();
  17. }
  18. }
  19. log.debug("有烟没?[{}]", hasCigarette);
  20. if (hasCigarette) {
  21. log.debug("可以开始干活了");
  22. }
  23. }
  24. }, "小南").start();
  25. new Thread(() -> {
  26. synchronized (room) {
  27. log.debug("外卖送到没?[{}]", hasTakeout);
  28. while (!hasTakeout) {
  29. log.debug("没外卖,先歇会!");
  30. try {
  31. room.wait();
  32. } catch (InterruptedException e) {
  33. e.printStackTrace();
  34. }
  35. }
  36. log.debug("外卖送到没?[{}]", hasTakeout);
  37. if (hasTakeout) {
  38. log.debug("可以开始干活了");
  39. } else {
  40. log.debug("没干成活...");
  41. }
  42. }
  43. }, "小女").start();
  44. Sleeper.sleep(1);
  45. new Thread(() -> {
  46. synchronized (room) {
  47. hasTakeout = true;
  48. log.debug("外卖到了噢!");
  49. room.notifyAll();
  50. }
  51. }, "送外卖的").start();
  52. }
  53. }

因为改为while如果唤醒之后, 就在while循环中执行了, 不会跑到while外面去执行”有烟没…”, 此时小南就不需要每次notify, 就去看是不是送来的烟, 如果是烟, while就为false了。
小结:
image.png

同步模式之保护暂停(join、Future的实现)

这里的保护性暂停主要用在一个线程等待另一个线程的执行结果的时候。 要点如下:
image.png

  1. 如果有一个结果需要从一个线程传递到另一个线程,让他们关联同一个 GuardedObject对象充当桥梁进行传输。
  2. 如果有结果源源不断从一个线程到另一个线程那么可以使用另外一种模式:消息队列(见生产者/消费者)
  3. JDK 中,join 的实现、Future 的实现,采用的就是保护性暂停模式
  4. 因为要等待另一方的结果,因此归类到同步模式
  • 一方等待另一方的执行结果举例 : 线程1等待线程2下载的结果,并获取该结果

image.png
一开始t1去获取内容,发现Guarded Suspension对象中没有值,所以要调用之前学的wait/notify方法进行等待,t2负责产生信息并将其放在Guarded Suspension对象中。Guarded Suspension对象有内容之后就会去唤醒t1去获取内容;

  1. /**
  2. * Description: 多线程同步模式 - 一个线程需要等待另一个线程的执行结果
  3. *
  4. * @author guizy1
  5. * @date 2020/12/21 14:51
  6. */
  7. @Slf4j(topic = "guizy.GuardeObjectTest")
  8. public class GuardeObjectTest {
  9. public static void main(String[] args) {
  10. // 线程1等待线程2的下载结果
  11. GuardeObject guardeObject = new GuardeObject();
  12. new Thread(() -> {
  13. log.debug("等待结果,直到线程2下载完成");
  14. List<String> list = (List<String>) guardeObject.get();
  15. log.debug("结果大小:{}", list.size());
  16. }, "t1").start();
  17. new Thread(() -> {
  18. log.debug("执行下载");
  19. try {
  20. //download一个下载的方法,不用管
  21. List<String> list = Downloader.download();
  22. //将返回的结果传递给线程1,顺便把线程一唤醒
  23. guardeObject.complete(list);
  24. } catch (IOException e) {
  25. e.printStackTrace();
  26. }
  27. }, "t2").start();
  28. }
  29. }
  30. class GuardeObject {
  31. // 结果
  32. private Object response;
  33. // 获取结果
  34. public Object get() {
  35. synchronized (this) {
  36. // 防止虚假唤醒
  37. // 没有结果
  38. while (response == null) {
  39. try {
  40. this.wait();
  41. } catch (InterruptedException e) {
  42. e.printStackTrace();
  43. }
  44. }
  45. return response;
  46. }
  47. }
  48. // 产生结果
  49. public void complete(Object response) {
  50. synchronized (this) {
  51. // 给结果变量赋值
  52. this.response = response;
  53. this.notifyAll();
  54. }
  55. }
  56. }

线程t1 等待 线程t2的结果, 可以设置超时时间, 如果超过时间还没返回结果,此时就不等了.退出while循环

  1. @Slf4j(topic = "guizy.GuardeObjectTest")
  2. public class GuardeObjectTest {
  3. public static void main(String[] args) {
  4. // 线程1等待线程2的下载结果
  5. GuardeObject guardeObject = new GuardeObject();
  6. new Thread(() -> {
  7. log.debug("begin");
  8. Object obj = guardeObject.get(2000);
  9. log.debug("结果是:{}", obj);
  10. }, "t1").start();
  11. new Thread(() -> {
  12. log.debug("begin");
  13. // Sleeper.sleep(1); // 在等待时间内
  14. Sleeper.sleep(3);
  15. guardeObject.complete(new Object());
  16. }, "t2").start();
  17. }
  18. }
  19. class GuardeObject {
  20. // 结果
  21. private Object response;
  22. // 获取结果
  23. // timeout表示等待多久. 这里假如是2s
  24. public Object get(long timeout) {
  25. synchronized (this) {
  26. // 假如开始时间为 15:00:00
  27. long begin = System.currentTimeMillis();
  28. // 经历的时间
  29. long passedTime = 0;
  30. while (response == null) {
  31. // 这一轮循环应该等待的时间
  32. long waitTime = timeout - passedTime;
  33. // 经历的时间超过了最大等待时间, 退出循环
  34. if (waitTime <= 0) {
  35. break;
  36. }
  37. try {
  38. // this.wait(timeout)的问题: 虚假唤醒在15:00:01的时候,此时response还null, 此时经历时间就为1s,
  39. // 进入while循环的时候response还是空,此时判断1s<=timeout 2s,此时再次this.wait(2s)吗,此时已经经历了
  40. // 1s,所以只要再等1s就可以了. 所以等待的时间应该是 超时时间(timeout) - 经历的时间(passedTime)
  41. this.wait(waitTime);
  42. } catch (InterruptedException e) {
  43. e.printStackTrace();
  44. }
  45. // 经历时间
  46. passedTime = System.currentTimeMillis() - begin; // 15:00:02
  47. }
  48. return response;
  49. }
  50. }
  51. // 产生结果
  52. public void complete(Object response) {
  53. synchronized (this) {
  54. // 给结果变量赋值
  55. this.response = response;
  56. this.notifyAll();
  57. }
  58. }

image.png

Join原理

join的原理其实就是利用了保护性暂停的原理实现的;虽然实现方法大致相同但这两者之间当然也会有所区别,保护性暂停是等待另一个线程的某个结果,而join是等带某个线程执行结束。
查看join的源码发现其无参构造函数中,其实就是传递了一个0作为参数调用有参构造函数,而且在线程存活期间一直去调用wati( 0 ) 方法进行一直等待;
若果传递的参数是大于0的,那么在超过一段时间之后就会break结束循环,不再等待;

park & unpack (重要)

  • 与Object的wait & notify相比, wait,notify和notifyAll必须配合Object Monitor一起使用,而park,unpark不必。
  • park & unpark是以线程为单位来【阻塞】和【唤醒】线程,而notify 只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程,就不那么【精确】。
  • park & unpark 可以先unpark,而wait & notify不能先notify

    1、基本使用

  • park/unpark都是LockSupport工具类中的的方法

  • 先调用unpark后,再调用park, 此时park不会暂停线程

// 暂停当前线程,线程状态是WAIT
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark(thread);
演示:
image.png
结果image.png

2、 park、 unpark 原理

每个线程都有自己的一个 Parker 对象(比较底层的c代码实现的),由三部分组成 counter, _cond和 _mutex。
打个比喻线程就像一个旅人,Parker 就像他随身携带的背包,条件变量
cond就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足)

  • 调用 park 就是要看需不需要停下来歇息
    • 如果备用干粮耗尽,那么钻进帐篷歇息
    • 如果备用干粮充足,那么不需停留,继续前进
  • 调用 unpark,就好比补充干粮,令干粮充足

    • 如果这时线程还在帐篷,就唤醒让他继续前进
    • 如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进。
    • 因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮

      先调用park再调用upark的过程

  • 先调用park的情况

    • 当前线程调用 Unsafe.park() 方法
    • 检查 _counter, 本情况为0, 这时, 获得_mutex 互斥锁(mutex对象有个等待队列 _cond)
    • 线程进入 _cond 条件变量阻塞
    • 设置_counter = 0 (没干粮了)

image.png

  • 调用unpark
    • 调用Unsafe.unpark(Thread_0)方法,设置_counter 为 1
    • 唤醒 _cond 条件变量中的 Thread_0
    • Thread_0 恢复运行
    • 设置 _counter 为 0

image.png

先调用upark再调用park的过程

  • 调用 Unsafe.unpark(Thread_0)方法,设置 _counter 为 1
  • 当前线程调用 Unsafe.park() 方法
  • 检查 _counter,本情况为 1,这时线程 无需阻塞,继续运行
  • 设置 _counter 为 0

image.png