JavaWeb~多线程带来的风险(线程安全问题)--synchronized和volatile关键字的使用

1.体会线程的不安全

public class ThreadTest {
    static class Count {

        public int count = 0;

        public void increase() {
            count++;
        }

    }
    public static void main(String[] args) throws InterruptedException {
        Count count = new Count();
        
        Thread t = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    count.increase();
                }
            }
        };
        
        Thread t1 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    count.increase();
                }
            }
        };

        t.start();
        t1.start();
        t.join();
        t1.join();
        System.out.println(count.count);
    }
}

  • 结果:
88477
  • 多次运行上述代码可以发现其结果并不都是10 0000 而是在50000~10 0000之间,这与我们认为的逻辑上发生冲突,这就是一个线程的安全问题

2.了解线程安全的概念

  • 如果多线程环境下代码运行的结果符合我们逻辑上的预期的,即是在单线程环境下应该得到的结果,则我们就说这个线程是安全的
  • 如果多线程并发执行某个代码,有逻辑上的错误,那就是线程不安全

3.线程不安全的几大原因

3.1线程是抢占式执行(不安全的万恶之源)

  • 线程之间的调度完全有内核负责,用户代码中是感知不到的,也无法控制的(如线程之间谁先执行,谁后执行谁执行到哪里从CPU上下来,这样的过程都是欧冠胡无法控制也无法感知的)
  • 所以如上述的increase让count++操作可以分为三步
  1. load 将内存中的数据读取到CPU中
  2. incr 将CPU中的数据++
  3. save 将结果保存到内存中
  • 当CPU执行到上面三个步骤任何一步的时候,都可能会被调度器调走,让给其他线程来执行,或者有多个CPU让修改同一个数据的俩个线程同时执行了, 这俩个都会造成线程不安全
  • 比如上述代码的 t 线程 读取数据,让数据++之后还未将数据保存在内存中, t1 线程就又一次读取了数据进行++操作,这样看似执行了俩次++操作,但最后往内存中保存数据的时候就只是执行了一次++的那个数据, 就造成了最后的结果不是10 0000原因

3.2不是原子性的

  • 如果有办法将上述三个load incr sava 操作变成一个整体,就是说只要执行了一次操作就必须把三个步骤都执行完, 才能让其他线程去执行这三个操作中的任何一个,这就是后面要说到的上锁操作.
  • 不保证原子性会给多线程带来什么问题?
    如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。比如一开始那个例子

3.3多个线程尝试修改同一个变量

这是最经典也是肯定会出现线程不安全的 所以必须上锁

  • 有以下几个是情况即使不上锁是线程安全的
  1. 如果是一个线程修改一个变量 线程安全
  2. 如果是多个线程尝试去读取同一个变量 线程安全
  3. 如果是多个线程去修改不同的变量 也是线程安全
  • 但是 如果是多个线程一个读数据 一个修改数据 此时也可能导致线程不安全

3.4内存可见性导致的线程安全问题

  • 为了提高效率,JVM在执行过程中,会尽可能的将数据在工作内存中(也就是CPU)执行,但这样虽然提高了效率会造成一个问题,共享变量在多线程之间不能及时看到改变,这个就是可见性问题。

  • 最典型的就是一个线程读数据 一个线程写(修改)数据此时读数据的线程可能会将数据读到cpu中 然后会造成改数据的那个线程修改了线程但是没有让读数据的那个线程识别到 造成线程不安全 这就是内存可见性问题

  • 如下面代码
    下面代码就是在线程 t 中一直是在获取count中的数据 所以编译器就对其进行优化 将count值一开始就读入CPU中 所以在此后线程 t1 对count值修改后 线程 t 并不能知道

public class ThreadTest {
    static class Count {
        public int count = 0;

        public void increase() {
            count++;
        }
    }

    public static void main(String[] args) {
        Count count = new Count();
        Thread t = new Thread() {
            @Override
            public void run() {
                while (count.count == 0) {

                }
                System.out.println("线程t执行完毕");
            }
        };

        Thread t1 = new Thread() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("进行count++");
                count.increase();
            }
        };

        t.start();
        t1.start();

    }
}

3.4.1volatile的理解

  • volatile 是用来解决一个线程读 一个线程写的场景
  • 如果是俩个线程写数据 就需要用加锁来解决
  • 加了volatile之后,对这个内存的读取操作肯定是从内存中读取,不加的时候,读取操作就可能不是在内存中读取而是自己读取CPU上旧的数据

3.4.2volatile的使用

  • 程序员想让哪个数据每次读取都得在内存中读取就在哪个数据的初始化前面加上volatile
    在这里插入图片描述

3.5指令重排序导致多线程不安全

  • Java的编译器在编译代码时,会针对指令进行优化,调整指令的先后顺序,保存原有逻辑不变的情况下,提高程序的运行速率
  • 现代编译器优化能力很强,优化后的代码执行速率很快,单线程的情况下不会有问题, 但是在多线程的优化是不容易实现的,可能会导致出现问题
  • 比如下面的操作:
  1. 去校门口卖吃的
  2. 写作业
  3. 去校门口拿快递
  • 如果是单线程就会优化为1 -> 3 -> 2 方式执行 可以优化过程 这就是指令重排序
  • 但是如果实在多线程下就会出错 因为可能是在你写作业的时候, 快递才被送来 或者在你写作业的时候快递会被人修改一些东西的时候,如果此时进行了指令重排序 代码就会出错
  • 如下面代码:
public class ThreadTest2 {
    private static class Counter {
        private int n1 = 0;
        private int n2 = 0;
        private int n3 = 0;
        private int n4 = 0;
        private int n5 = 0;
        private int n6 = 0;
        private int n7 = 0;
        private int n8 = 0;
        private int n9 = 0;
        private int n10 = 0;

        public void write() {
            n1 = 1;
            n2 = 2;
            n3 = 3;
            n4 = 4;
            n5 = 5;
            n6 = 6;
            n7 = 7;
            n8 = 8;
            n9 = 9;
            n10 = 10;
        }

        public void read() {
            System.out.println("n1 = " + n1);
            System.out.println("n2 = " + n2);
            System.out.println("n3 = " + n3);
            System.out.println("n4 = " + n4);
            System.out.println("n5 = " + n5);
            System.out.println("n6 = " + n6);
            System.out.println("n7 = " + n7);
            System.out.println("n8 = " + n8);
            System.out.println("n9 = " + n9);
            System.out.println("n10 = " + n10);
        }
    }
    public static void main(String[] args) {
        Counter nums = new Counter();

        Thread t = new Thread() {
            @Override
            public void run() {
                nums.read();
            }
        };

        Thread t1 = new Thread() {
            @Override
            public void run() {
                nums.write();
            }
        };

        t.start();
        t1.start();

    }

}

  • 测试结果:
n1 = 0
n2 = 0
n3 = 0
n4 = 4
n5 = 5
n6 = 6
n7 = 7
n8 = 8
n9 = 9
n10 = 10

4.如何解决线程不安全问题

  1. 改抢占式执行(这个有操作系统内核实现的检查调度的方式,无法解决)
  2. 将操作变为原子性的(这个可以实现 上锁就行 而且适用范围广)
  3. 不让多个线程修改同一个变量(这个能不能有办法不一定,因为得看具体需求)

4.1锁的特点

  • 锁的基本操作 加锁(获取锁)lock 解锁(释放锁) unlock
  • 互斥的 同一时刻只有一个线程能获取到同一把锁
  • 其他线程如果尝试获取同一把锁 就会发生阻塞等待
  • 一直等到某个线程释放了这把锁 此时剩下的想要这把锁的线程会重新竞争这把锁

4.2锁如何解决线程不安全问题?

public class ThreadTest {
    static class Count {

        public int count = 0;

        synchronized public void increase() {
            count++;
        }

    }
    public static void main(String[] args) throws InterruptedException {
        Count count = new Count();

        Thread t = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    count.increase();
                }
            }
        };

        Thread t1 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    count.increase();
                }
            }
        };

        t.start();
        t1.start();
        t.join();
        t1.join();
        System.out.println(count.count);
    }
}

  • 如上述例子 在increase方法前加上锁(synchronized)
  1. 假设上述俩个线程 如果一开始 t 获取到锁把 锁 锁住了 此时线程 t1 爱去获取锁的时候就会发现锁已经锁死 t1 就会阻塞等待 直到 t 线程把锁释放了 线程2 才有可能获取到锁
  2. 有了锁 即使假如线程 t 执行了一半被调度走了 也没关系 因为 线程 t 还未释放锁 (一个线程只有把上锁的那部分代码全部执行完才会释放锁) 所以 t1 线程还是获取不到锁 这也就保证了上锁那部分代码的原子性
  • 还需要知道锁这个东西并不那么容易
  1. 使用的时候一定好主意正确的方式使用 不然会出现很多问题
  2. 一旦上锁 这个代码就和高性能无缘了 因为锁的等待时间是不可控制的 可能会等待很久很久

4.3理解synchronized(锁)

synchronized的底层是使用操作系统的mutex lock实现的。

  • 当线程释放锁时,JVM会把该线程对应的工作内存中的共享变量刷新到主内存中
  • 当线程获取锁时,JVM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
    synchronized用的锁是存在Java对象头里的。
  • synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。
  • 加锁 相当于给当前对象加锁 (所谓加锁其实就是某个指定的对象来加锁) (因为在你new这个对象的时候,编译器会自动加一个对象头,这个对象头就包含一个具体的字段 这个字段就有一部分表示当前对象是不是一个加锁状态, 可以想象成一个Boolean 未加锁就是 false 加锁了就是 true)
  • 总而言之:
    - 所以在加锁前必须明确给哪个对象加锁

4.4synchronized的使用

4.4.1synchronized 关键字写到普通方法前

  • 如下面例子:
    当线程开始执行后进入method方法后 会锁test指向的对象中的, 出方法后 会释放test指向对象中的锁
public class ThreadTest4 {
    static class Test {
        synchronized public void methond() {
            System.out.println("haha");
        }
    }

    public static void main(String[] args) {
        Test test = new Test();
        Thread thread = new Thread() {
            @Override
            public void run() {
                test.methond();
            }
        };
        thread.start();
    }
}

4.4.2synchronized写到静态方法前

表示锁当前类的类对象
类对象就是反射的实现依据, JVM运行时把.class文件加载到内存中获取的(类加载)

  • 如下面代码:
    进入方法会锁 Test.class 指向对象中的锁;出方法会释放 Test.class 指向的对象中的锁
public class ThreadTest4 {
    static class Test {
        synchronized public void methon() {
            System.out.println("haha");
        }
    }

    public static void main(String[] args) {
        Test test = new Test();
        Thread thread = new Thread() {
            @Override
            public void run() {
                test.methon();
            }
        };
        thread.start();
    }
}

4.4.3synchronized写到某个代码块之前

指定给某个对象加锁

  • 如下面代码
    使用同一把锁 给俩个代码块上锁 如果线程 t 要输入一个数字(要进行IO操作)会进入阻塞 但是这个线程并没有把锁释放 那么线程 t1 也就不能正常运行了
import java.util.Scanner;

public class ThreadTest5 {
    public static void main(String[] args) {
        Object lock = new Object();
        Thread t = new Thread() {
            @Override
            public void run() {
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock) {
                    System.out.println("请输入一个数字:");
                    Scanner scanner = new Scanner(System.in);
                    int num = scanner.nextInt();
                    System.out.println("num" + "=" + num);
                }
            }
        };

        Thread t1 = new Thread() {
            @Override
            public void run() {
                while (true) {
                    synchronized (lock) {
                        System.out.println("我t1得到锁了");
                        try {
                            Thread.sleep(1000);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        };

        t.start();
        t1.start();

    }
}

  • 结果:
我t1得到锁了
我t1得到锁了
我t1得到锁了
我t1得到锁了
我t1得到锁了
我t1得到锁了
我t1得到锁了
我t1得到锁了
请输入一个数字:
2
num=2
我t1得到锁了
我t1得到锁了
我t1得到锁了
我t1得到锁了
  • 如果俩个线程上的俩把锁自然不会发生上面的情况 但是还有一种情况就是锁是object的类对象那样也就把锁看成了一把锁 因为类对象只有一个 (如果是不同类型就不会发生这样的情况) (java中任何一个对象都可能成为锁)
import java.util.Scanner;

public class ThreadTest5 {
    public static void main(String[] args) {
        Object lock = new Object();
        Object lock2 = new Object();
        Thread t = new Thread() {
            @Override
            public void run() {
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock.getClass()) {
                    System.out.println("请输入一个数字:");
                    Scanner scanner = new Scanner(System.in);
                    int num = scanner.nextInt();
                    System.out.println("num" + "=" + num);
                }
            }
        };

        Thread t1 = new Thread() {
            @Override
            public void run() {
                while (true) {
                    synchronized (lock2.getClass()) {
                        System.out.println("我t1得到锁了");
                        try {
                            Thread.sleep(1000);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        };

        t.start();
        t1.start();

    }
}

  • 结果:
我t1得到锁了
我t1得到锁了
我t1得到锁了
我t1得到锁了
我t1得到锁了
我t1得到锁了
我t1得到锁了
我t1得到锁了
我t1得到锁了
我t1得到锁了
请输入一个数字:
3
num=3
我t1得到锁了
我t1得到锁了
我t1得到锁了

猜你喜欢

转载自blog.csdn.net/Shangxingya/article/details/106640329
今日推荐