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(); } }
-
运行结果:
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. 实例
-
StringBuilder
和StringBuffer
间的区别就在于有无使用synchronized
修饰.
后者使用了同步修饰, 因此性能没有前者高, 但是线程安全. -
ArrayList
和Vector
/HashMap
和HashTable
的区别也如上.
后者都使用了同步修饰, 因此性能没有前者高, 但是线程安全.
-
建议: 减少
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` 关键字可能会屏蔽掉虚拟机中一些必要的代码优化, 所以运行效率并不高.
因此一般建议, 没有特别的需要, 不要使用. 也就是说, 虽然可以使用"双重检查加锁"机制
来实现线程安全的单例, 但并不建议大量采用, 可以根据情况来选用. 更推荐使用饿汉式.