1. 问题引出:

两个线程对初始值为 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. log.debug("{}",counter);
  18. }

问题分析

以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析
例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:

  1. getstatic i // 获取静态变量i的值
  2. iconst_1 // 准备常量1
  3. iadd // 自增
  4. putstatic i // 将修改后的值存入静态变量

而对应i—也类似:

  1. getstatic i // 获取静态变量i的值
  2. iconst_1 // 准备常量1
  3. isub // 自减
  4. putstatic i // 将修改后的值存入静态变量i

而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换 :
主内存.png

如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题
但多线程下这 8 行代码可能交错运行,出现负数的情况

临界区 Critical Section

  • 一个程序运行多个线程本身是没有问题的
  • 问题出在多个线程访问共享资源
    • 多个线程读共享资源其实也没有问题
    • 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
  • 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区

例如,下面代码中的临界区

  1. static int counter = 0;
  2. static void increment()
  3. // 临界区
  4. {
  5. counter++;
  6. }
  7. static void decrement()
  8. // 临界区
  9. {
  10. counter--;
  11. }

竞态条件 Race Condition

多个线程在临界区内执行,由于代码的执行顺序不同而导致结果无法预测,称之为发生了竞态条件

2. synchronized解决方案

为了避免临界区的竞态条件发生,有多种手段可以达到目的。

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

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

注意
虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:
互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点

synchronized

语法:

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

解决

  1. static int counter = 0;
  2. static final Object room = new Object();
  3. public static void main(String[] args) throws InterruptedException {
  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. log.debug("{}",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(obj) 放在 for 循环的外面,如何理解?— 原子性
  • 如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?— 锁对象
  • 如果 t1 synchronized(obj) 而 t2 没有加会怎么样?如何理解?— 锁对象


面向对象改进

把需要保护的共享变量放入一个类

  1. class Room {
  2. int value = 0;
  3. public void increment() {
  4. synchronized (this) {
  5. value++;
  6. }
  7. }
  8. public void decrement() {
  9. synchronized (this) {
  10. value--;
  11. }
  12. }
  13. public int get() {
  14. synchronized (this) {
  15. return value;
  16. }
  17. }
  18. }
  19. @Slf4j
  20. public class Test1 {
  21. public static void main(String[] args) throws InterruptedException {
  22. Room room = new Room();
  23. Thread t1 = new Thread(() -> {
  24. for (int j = 0; j < 5000; j++) {
  25. room.increment();
  26. }
  27. }, "t1");
  28. Thread t2 = new Thread(() -> {
  29. for (int j = 0; j < 5000; j++) {
  30. room.decrement();
  31. }
  32. }, "t2");
  33. t1.start();
  34. t2.start();
  35. t1.join();
  36. t2.join();
  37. log.debug("count: {}" , room.get());
  38. }
  39. }

3. 方法上的synchronized

  1. class Test{
  2. public synchronized void test() {
  3. }
  4. }
  5. 等价于
  6. class Test{
  7. public void test() {
  8. // 加在对象上的锁
  9. synchronized(this) {
  10. }
  11. }
  12. }
  1. class Test{
  2. public synchronized static void test() {
  3. }
  4. }
  5. 等价于
  6. class Test{
  7. public static void test() {
  8. // 加在类对象上的锁
  9. synchronized(Test.class) {
  10. }
  11. }
  12. }

不加 synchronized 的方法

不加 synchronzied 的方法就好比不遵守规则的人,不去老实排队(好比翻窗户进去的)

4. 变量的线程安全分析

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

  • 如果它们没有共享,则线程安全
  • 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况

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

      局部变量是否线程安全?

  • 局部变量是线程安全的(局部变量存储在栈中,线程私有)

每个线程调用特定方法时,局部变量会在每个线程的栈帧内存中被创建多份,因此不存在共享

  • 但局部变量引用的对象则未必
    • 如果该对象没有逃离方法的作用访问,它是线程安全的
    • 如果该对象逃离方法的作用范围(方法外有该变量的引用),需要考虑线程安全

代码:

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

上述代码的结果:
如果线程2 还未 add,线程1 remove 就会报错

分析:
无论哪个线程中的 method2 引用的都是同一个对象中的 list 成员变量
method3 与 method2 分析相同

解决办法:
将list变量改为局部变量

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

分析:

  • list 是局部变量,每个线程调用时会创建其不同实例,没有共享
  • 而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象
  • method3 的参数分析与 method2 相同

方法访问修饰符带来的思考,如果把 method2 和 method3 的方法修改为 public 会不会代理线程安全问题?
单独修改为public不会,要满足下面两个条件

  • 情况1:有其它线程调用 method2 和 method3
  • 情况2:在 情况1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法,有可能通过子类修改list

判断一个类是否线程安全,一般情况下如果其没有成员变量,那么它基本上就是线程安全的,一个线程安全的类的对象作为其他类的成员变量时,其他类对于这个变量是线程安全的。所以在使用成员变量时一定要三思是否考虑了线程安全。另外局部变量也要看其作用范围是否暴露给了外面,例如在方法内将局部变量传递给了一个本类的抽象方法,这样导致在该类的子类有可能对该变量进行修改。如果不想被子类访问,尽量将方法设为final.

常见线程安全类

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent 包下的类

这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为它们的每个方法是原子的,但注意它们多个方法的组合不是原子的,例如:

  1. Hashtable table = new Hashtable();
  2. // 线程1,线程2
  3. if( table.get("key") == null) {
  4. table.put("key", value);
  5. }

上述代码是线程不安全的,get和put操作的组合不是原子操作

5. Monitor概念

5.1 Java对象头

6. wait notify

7. park unpark

8. 活跃性

9. ReentrantLock