049.JAVA线程_线程同步


博主的 Github 地址


1. 线程同步的方式

有三种保证线程同步的方法:

  • 同步代码块
  • 同步方法
  • 同步锁机制/Lock 接口(java.util.concurrent.locks)

1.1. 同步代码块

1.1.1. 语法

synchronized(/*LOCK*/){
  //TODO...
}

1.1.2. 同步锁的概念

  • 为了保证每个线程都能正常执行原子操作, JAVA 引入了线程同步机制.

  • 同步锁也称为同步监听对象/同步监听器/互斥锁.

  • 对象的同步锁只是一个概念, 可以想象为在对象上标记了一个锁.

  • JAVA 程序允许把任何对象作为同步监听对象, 但一般来说,
    会去选择当前并发访问的共同资源作为同步监听对象.

  • 注意:
    在任何时候最多允许一个线程拥有同步锁.
    哪个线程拿到了锁哪个线程就进入代码块,
    然后对要求同步的资源进行修改,
    其余线程只能等待.

1.1.3. 同步代码块实例

  • 代码根据之前的苹果案例进行修改

    class Apple implements Runnable {
        private int apple_num = 10;
    
        public void run() {
            while (true) {
                //在堆数据进行操作的部分加入同步代码块
                synchronized (this) {
                    if (apple_num > 0) {
                        try {
                            //延迟设置为0
                            Thread.sleep(0);
                        } catch (Exception ex) {
                            ex.printStackTrace();
                        }
                        System.out.printf("the apple[%d] is eaten by [%s]\n", apple_num--, Thread.currentThread().getName());
                    } else break;
                }
            }
        }
    }
    
    public class Test {
        public static void main(String[] args) {
            Apple a = new Apple();
            new Thread(a, "A").start();
            new Thread(a, "B").start();
            new Thread(a, "C").start();
        }
    }
  • 运行结果:
    049.JAVA线程_线程同步

1.1.4. 注意事项

  • 切忌不能将循环放入代码块中, 否则抢到资源的线程将会占满整个循环.
    因为循环是在代码块当中, 而同步代码块就是用于线程执行完后其它线程才能执行.

  • 同时要在同步代码块中加入总数的非零判断, 如果在同步代码块之外放置,
    就会出现在总数为 1 的瞬间, 三个线程都能判定有资格执行同步代码块,
    而此时数据还未被修改, 所以最终会输出 0 和负数结果.


1.2. 同步方法

  • 使用 synchronized 修饰的方法就叫同步方法.
  • 保证了 A 线程执行该方法的时候, 其他线程不能对资源进行修改.

1.2.1. 语法

synchronized function(para){
  //TODO...
}

1.2.2. 同步方法中的同步锁

  • 对于非静态方法, 同步锁就是 this 即当前调用方法的对象.
  • 对于静态方法, 同步锁则是当前方法所在类的字节码对象.

1.2.3. 同步方法的实例

class Apple implements Runnable {

    private int apple_num = 10;

    public void run() {
        while (true) {
            eatApple();
            if (apple_num == 0) break;
        }
    }

    //定义一个锁对象, 用其实现类来进行构造对象.
    private final Lock lock = new ReentrantLock();

    public void eatApple() {
        //进入方法的时候就上锁
        lock.lock();

        try {
            //再次检验苹果数量
            if (apple_num <= 0) return;

            Thread.sleep(10);
            System.out.printf("the apple[%d] is eaten by [%s]\n", apple_num--, Thread.currentThread().getName());
        } catch (Exception ex) {
            ex.printStackTrace();
        } finally {
            //方法执行完后必须释放锁
            lock.unlock();
        }
    }
}

1.2.4. 注意事项

  • synchronized 不能修饰 run 方法, 否则也会导致单一线程执行完所有功能,
    最终导致其它线程闲置, 不能达到多线程的效果.

  • 需要注意非零判断的位置.


1.3. 同步锁机制/Lock 接口

  • Lock 机制提供了比 synchronized 代码块和 synchronized 方法更广泛的锁定操作.

  • 因此同步代码块/同步方法所拥有的功能 Lock 都有, 而且更为强大也更能体现面向对象.

  • 常用方法有两个: lock()unlock() 分别是获取锁和释放锁.

  • 由于 Lock 是接口, 因此需要用已实现接口的子类的对象进行方法的调用,
    通常会使用ReentrantLock 类, 可重入互斥锁类.

  • 最常见的调用方式实例:

    class X {
        private final ReentrantLock lock = new ReentrantLock();
        // ...
        public void m() {
            lock.lock();// block until condition holds
            try {
                // ... method body
            } finally {
                lock.unlock()
            }
        }
    }

1.3.1. 用 Lock 机制修改后的吃苹果实例

class Apple implements Runnable {

  private int apple_num = 10;

  public void run() {
      while (true) {
          eatApple();
          if (apple_num <= 0) break;
      }
  }

  //定义一个锁对象, 用其实现类来进行构造对象. 
  private final Lock lock = new ReentrantLock();

  public void eatApple() {
    //进入方法的时候上锁
    lock.lock();

    try {
        Thread.sleep(10);
    } catch (Exception ex) {
        ex.printStackTrace();
    } finally {
      //方法执行完后必须释放锁
      lock.unlock();
    }
    System.out.printf("the apple[%d] is eaten by [%s]\n", apple_num--, Thread.currentThread().getName());
  }
}

1.3.2. 同步锁池的概念

  • 同步锁必须选择多个线程共同的资源对象.

  • 当前一个线程在获取和修改数据的时候, 会先拥有同步锁,
    由于锁只有一个, 因此其他线程就只能在锁池中等待获取锁.

  • 当拥有锁的线程在执行完同步代码块的之后, 就会释放同步锁,
    然后在同步锁池中的线程就会开始抢锁的使用权.

2. synchronized 的引申拓展

2.1. synchronized 的优缺点

2.1.1. 优点

保证了多线程并发访问时的同步操作, 避免线程的安全问题.

2.1.2. 缺点

使用 synchronized 修饰的代码块/方法更加耗费性能.

2.1.3. 实例

  • StringBuilderStringBuffer 间的区别就在于有无使用 synchronized 修饰.
    后者使用了同步修饰, 因此性能没有前者高, 但是线程安全.

  • ArrayListVector / HashMapHashTable 的区别也如上.
    后者都使用了同步修饰, 因此性能没有前者高, 但是线程安全.

  • 建议: 减少 synchronized 修饰的作用域.

2.2. 懒加载同步的优化

  • 设计一个单例模式的工具类, 如下所示:

    public class TestUtils{
      //将构造方法私有化
      private TestUtils() {
    
      }
      //定义一个静态成员用以接受类实例化成员
      private TestUtils instance = null;
    
      //定义一个公共方法来让外部获取单例, 该方法必须用同步修饰
      synchronized public static TestUtils getInstance() {
        if(instance == null){
          instance = new TestUtils();
        }
        return instance;
      }
    
      //定义一个示例的工具方法
      public void sortArr(int[] arr) {
        //TODO...
      }
    }
  • 返回单例的方法必须用 synchronized 修饰, 用以保证线程安全.
    重点是能用来保证工具类始终只实例化了一个成员并返回它的引用.
    这样始终能让该工具类始终是单例.

  • 此时同步箭头的对象是该类的字节码文件, 即 TestUtils.class

  • 由于上述代码 synchronized 修饰的范围过大, 可以继续进行优化.
    解决方案是使用双重检查锁机制.

2.3. 双重检查锁机制

  • 使用"双重检查加锁"的方式来实现方法, 可以既实现线程安全, 又能使性能不受很大影响.

  • 如下是用 volatile 修饰的类成员变量和用双重检查锁机制实现的 getInstance 方法.

    private volatile TestUtils instance = null;
    

public static TestUtils getInstance() { if(instance == null){ synchronized(TestUtils.class){ if(instance == null){ instance = new TestUtils(); } } } return instance; }


#### 2.3.1. 双重检查加锁机制的原理  
- 并不是每次进入 `getInstance` 方法都需要同步, 而是先不同步;   
- 进入方法后, 先检查实例是否存在, 如果不存在才进行下面的同步块, 这是第一重检查;  
- 进入同步块过后, 再次检查实例是否存在, 如果不存在, 就在同步的情况下创建一个实例, 这是第二重检查;  
- 这样一来, 就只需要同步一次了, 从而减少了多次在同步情况下进行判断所浪费的时间.  

#### 2.3.2. volatile 关键字
- "双重检查加锁"机制的实现会使用关键字 `volatile`, 它的作用是:  
  被 `volatile` 修饰的变量的值, 将不会被本地线程缓存,   
  所有对该变量的读写都是直接操作共享内存, 从而确保多个线程能正确的处理该变量.  

#### 2.3.3. 关于双重检查锁机制的注意事项
- 注意:  
  在 java1.4 及以前版本中, 很多 JVM 对于 `volatile` 关键字的实现的问题,  
  会导致"双重检查加锁"的失败, 因此"双重检查加锁"机制只只能用在 java5 及以上的版本. 

- 提示:  
  由于 `volatile` 关键字可能会屏蔽掉虚拟机中一些必要的代码优化, 所以运行效率并不高.   
  因此一般建议,  没有特别的需要, 不要使用. 也就是说, 虽然可以使用"双重检查加锁"机制  
  来实现线程安全的单例, 但并不建议大量采用, 可以根据情况来选用. 更推荐使用饿汉式.

上一篇:049 模块6-wordcloud库的使用


下一篇:L1-049 天梯赛座位分配 (20分)