本篇文章以线程同步的相关内容为主。线程的同步机制主要用来解决线程安全问题,主要方式有同步代码块、同步方法等。首先来了解何为线程安全问题。
1、线程安全问题
卖票示例,4 个窗口卖 100 张票:
class Ticket implements Runnable {
private int total = 100;
@Override
public void run() {
while (total > 0) {
// 因为 Runnable 接口中的 run() 没有 throws 任何异常,因此实现类覆盖的方法也不能抛,只能try
// 根本原因是子类覆盖父类或接口所抛的异常,只能是父类方法抛的异常或其子类
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
total--;
System.out.println(Thread.currentThread().getName() + "..." + total);
}
}
}
public class Test {
public static void main(String[] args) {
Ticket ticket = new Ticket();
Thread[] threads = new Thread[4];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(ticket);
threads[i].start();
}
}
}
以上代码是有线程安全问题的。在 while 循环内 sleep() 后再操作 total,很容易就会出现票数为负数的情况。原因是有多个线程在操作共享的数据。
在上例中,total 变量是共享数据并且被多个线程操作了:
public void run() {
while (total > 0) {
// 在这里,线程执行权被切换到其它线程上
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
total--;
System.out.println(Thread.currentThread().getName() + "..." + total);
}
}
多个线程可能执行完 while 的判断条件进入循环之后,CPU 就切换了线程,去执行其他线程了,其他线程也能进到这个循环然后执行 total–。当执行权切换回来,此时可能 total > 0 的条件已经不满足了,但是程序仍会会接着执行 total–,导致 total 变为负数。
2、synchronized
使用 synchronized 关键字构造一个同步代码块或同步方法可以有效的解决线程安全问题。实际上,相当于将 synchronized 范围内的所有代码都变成了一个原子操作来保证线程安全的。
2.1 同步代码块
使用同步代码块将可能出现线程安全的代码包起来:
class Ticket implements Runnable {
private int total = 100;
private Object obj = new Object();
@Override
public void run() {
// 同步代码块,任何对象都可以作为锁,比如 Ticket.class 也可以作为锁
synchronized (obj) {
while (total > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
total--;
System.out.println(Thread.currentThread().getName() + "..." + total);
}
}
}
}
假如4个线程中,Thread-0 拿到了执行权,那么它就会把 obj 从 1 置为 0,这样其它线程在 synchronized(obj) 处进行判断的时候就无法进入同步代码块。虽然 Thread-0 会在 sleep(100) 期间释放掉执行权,但不会释放锁,所以其它线程也是无法进入同步代码块的。等 Thread-0 完全执行完同步代码,会把 obj 从 0 置为 1,其它线程就可以争夺执行权、加锁、执行代码了。
同步解决了线程的安全问题,但因为同步锁外的线程需要等待拿到锁之后才可以执行其任务,所以相对的降低了效率。
必须要注意一下同步的使用前提,即需要同步的线程必须有使用同一个锁。例如还是刚才的例子,改一处:
class Ticket implements Runnable {
private int total = 100;
@Override
public void run() {
// 把同步锁声明成方法内的局部变量
Object obj = new Object();
// 同步代码块
synchronized (obj) {
while (total > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
total--;
System.out.println(Thread.currentThread().getName() + "..." + total);
}
}
}
}
把所 obj 从成员变量变成了线程内的局部变量,这样就从 4 个线程共用 1 个 obj 锁变成了每个线程中都有一个 obj 锁,每个线程只使用自己的锁,使得同步失败。
2.2 同步方法
把 synchronized 关键字加在方法前就可以不用显式地使用对象来进行同步了,这就是同步方法。
仍然是卖票的例子,使用同步方法来做,该怎么做呢?直接这样:
class Ticket implements Runnable {
private int total = 100;
@Override
public synchronized void run() {
while (total > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
total--;
System.out.println(Thread.currentThread().getName() + "..." + total);
}
}
}
简单粗暴的把 synchronized 关键字加在 run 方法前,运行程序你会发现一直都是 Thread-0 在执行,其它线程根本没有卖票!!!
这是因为 Thread-0 进入方法后,一直满足 while 循环的条件,所以它会一直循环,直到 total = 0 走出循环再结束方法。也就是说,while 循环语句并不需要同步,产生线程安全问题的代码是 while 循环体内的代码。因此这样修改:
class Ticket implements Runnable {
private int total = 100;
@Override
public void run() {
while (true) {
show();
}
}
public synchronized void show() {
if (total > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
total--;
System.out.println(Thread.currentThread().getName() + "..." + total);
}
}
}
把产生安全问题的代码提到一个方法中,然后用 synchronized 修饰这个方法使其变成同步方法。
我们开头提过同步方法不用像同步代码块那样显式的指定锁对象,实际上它内部还是使用了锁对象的。对于成员同步方法而言,调用该方法的对象就是锁对象,即 this;对于静态同步方法而言,该方法所在的类对象就是锁对象,即 Xxx.class。这里要注意,有可能不同的线程产生不同的对象调用各自的成员同步方法,打破了唯一锁的规则,使得同步失败,因此建议使用同步代码块。
2.3 注意事项
同步代码块一般使用可能被并发访问的共享资源充当同步锁,或者干脆使用所在的类对象 Xxx.class。
synchronized 关键字可以修饰方法和代码块,但不能修饰构造器、成员变量。
可变类的线程安全是以降低程序的运行效率作为代价的,为了减少线程安全所带来的的负面影响,程序可以采用如下策略:
- 不要对线程安全类的所有方法都进行同步,只对那些会改变竞争资源(共享资源)的方法进行同步。
- 如果可变类有两种运行环境:单线程环境和多线程环境,则应该为该可变类提供两种版本,即线程安全版本(多线程环境中使用)和线程不安全版本(单线程环境中使用),例如 StringBuffer(安全) 和 StringBuilder(不安全)。
synchronized 同步锁的释放不是由程序显式控制的,以下介绍了哪些情况会释放锁,哪些不会:
3、线程间通信
synchronized 保证了线程安全,但是只有 synchronized 还远远不足以面对复杂的线程使用场景,比如多个线程在处理同一资源,但是任务却不同。举个例子,假设资源 Resource 有属性 count,现在有两个线程,一个去写 count,另一个读 count,要求两个线程交替执行。这个需求就需要用到线程间通信了。
3.1 wait()、notify() 与 notifyAll()
实现例子的思路:
- 在 Resource 内定义一个标记位 flag,表示当前数据是可读还是可写。
- 写线程拿到锁后,如果 Resource 可写,那么就写入数据并通知读线程;否则,进入等待状态,直到被读线程通知可以进行写操作。
- 读线程拿到锁后,如果 Resource 可读,那么就读取数据并通知写线程;否则,进入等待状态,直到被写线程通知可以进行读操作。
上述思路的实现需要用到 Object 中定义的方法:
- wait():让线程处于等待状态,被 wait() 的线程会被存储到线程池中。
- notify():随机唤醒线程池中的一个线程。
- notifyAll():唤醒线程池中的所有线程。
注意:
-
上述三个方法只能由拥有对象锁的线程调用,一个线程有三种方式拥有对象锁:
a) 执行 synchronized 修饰的对象同步方法
b) 执行 synchronized 修饰的静态同步方法
c) 执行持有该对象锁的 synchronized 同步代码块
也就是说必须在 synchronized 范围内使用,否则会抛出 IllegalMonitorStateException。
-
必须要明确到底操作的是哪个锁上的线程。只有知道了所属的锁,才能去唤醒这个锁上的其它线程,而处于等待状态的线程才能放到这个锁的线程池当中。
-
关于为什么这三个操作线程的方法被定义在 Object 类中:因为所有的对象都可以作为锁,也就是这个锁的方法存在于所有对象中,在 Java 中没有其它比 Object 这个所有类的父类更合适的定义地方了。
那么例子的实现代码可以这样:
public class ThreadCommunicationDemo1 {
static class Resource {
private int count;
// false 可写不可读,true 可读不可写
private boolean flag = false;
public void setCount(int count) {
this.count = count;
}
public int getCount() {
return count;
}
public boolean isFlag() {
return flag;
}
public void setFlag(boolean flag) {
this.flag = flag;
}
}
static class WriteThread implements Runnable {
private Resource mResource;
public WriteThread(Resource resource) {
mResource = resource;
}
@Override
public void run() {
while (true) {
synchronized (mResource) {
if (mResource.isFlag()) {
try {
// 一定要调用锁的 wait(),而不是直接调用 wait()
mResource.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
int count = new Random().nextInt(100);
mResource.setCount(count);
mResource.setFlag(true);
System.out.println("写入数据 count = " + count);
mResource.notify();
}
}
}
}
static class ReadThread implements Runnable {
private Resource mResource;
public ReadThread(Resource resource) {
mResource = resource;
}
@Override
public void run() {
while (true) {
synchronized (mResource) {
if (!mResource.isFlag()) {
try {
mResource.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
mResource.setFlag(false);
System.out.println("读取数据 count = " + mResource.getCount());
mResource.notify();
}
}
}
}
public static void main(String[] args) {
Resource resource = new Resource();
new Thread(new WriteThread(resource)).start();
new Thread(new ReadThread(resource)).start();
}
}
虽然上述代码确实能实现要求的功能,但是实现方式却很粗糙。把等待和唤醒操作从线程移入 Resource 中会好一点:
public class ThreadCommunicationDemo2 {
static class Resource {
private int count;
// false 可写不可读,true 可读不可写
private boolean flag = false;
public synchronized int getCount() {
if (!flag) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
flag = false;
notify();
return count;
}
public synchronized void setCount(int count) {
if (flag) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
this.count = count;
flag = true;
notify();
}
}
static class WriteThread implements Runnable {
private Resource mResource;
public WriteThread(Resource resource) {
mResource = resource;
}
@Override
public void run() {
while (true) {
synchronized (mResource) {
int count = new Random().nextInt(100);
mResource.setCount(count);
System.out.println(Thread.currentThread().getName() + "写入数据 count = " + count);
}
}
}
}
static class ReadThread implements Runnable {
private Resource mResource;
public ReadThread(Resource resource) {
mResource = resource;
}
@Override
public void run() {
while (true) {
synchronized (mResource) {
System.out.println(Thread.currentThread().getName() + "读取数据 count = " + mResource.getCount());
}
}
}
}
public static void main(String[] args) {
Resource resource = new Resource();
new Thread(new WriteThread(resource)).start();
new Thread(new ReadThread(resource)).start();
}
}
3.2 多生产者与多消费者
上面的例子如果放在多生产者多消费者模型中会暴露出线程安全问题,假如我们创建两个读线程,两个写线程,运行上面的程序会得到如下输出(Thread-0、1 是写线程,Thread-2、3 是读线程):
Thread-1写入数据 count = 89 ---> 正常的部分
Thread-2读取数据 count = 89
Thread-0写入数据 count = 70 ---> 写一次读两次
Thread-2读取数据 count = 70
Thread-3读取数据 count = 70
Thread-0写入数据 count = 73
Thread-2读取数据 count = 73
Thread-3读取数据 count = 73
...
Thread-1写入数据 count = 72 ---> 写两次读一次
Thread-0写入数据 count = 7
Thread-3读取数据 count = 7
以写一次读两次的异常状况为例,从 “Thread-2读取数据 count = 89” 这一行开始分析其中原因:
- Thread-2 刚读了一次 89,那么 flag 会被置成 false,表示可以写入;
- 4 个线程争夺执行权,假设 Thread-3 拿到了执行权,由于 flag = false,不能读取,所以调用到 wait() 进入线程池等待;
- 剩余的 3 个线程争夺执行权,Thread-2 拿到了执行权,同样的原因,它也要执行 wait() 进入线程池等待;
- 这下只剩 2 个写线程争夺执行权了,Thread-0 拿到执行权,写入了数据 70,并将 flag 置为 true,最后调用 notify() 唤醒一个线程池中等待的线程;
- Thread-2 被唤醒并且抢到执行权,它会接着 wait() 后面的代码继续执行,读取到数据并且唤醒线程池中仅剩的 Thread-3;
- Thread-3 被唤醒并且抢到执行权,它也不用再做 if 判断 flag 了,也是接着执行 wait() 之后的代码,也读取到数据,从而发生了线程安全问题。
可以判断,造成问题的原因是,执行过 wait() 被唤醒的线程,没有再次判断 flag。因此可以考虑把 if(flag) 改为 while(flag),这样在线程被唤醒之后会再次在循环条件处判断 flag,不过可能会出现死锁:
- 假设 Thread-0 和 Thread-1 在线程池中 wait(),这时候进来一个消费者 Thread-2,消费并唤醒了 Thread-0。(1等待,2、3、0活)
- Thread-2 和 Thread-3 先后执行被 wait()。(1、2、3等待,0活)
- Thread-0 执行并生产一次,唤醒了 Thread-1。(0、1活,2、3等待,此时 flag 为 true了)
- Thread-0 和 Thread-1 先后执行由于 flag 为 true 结果 wait(),至此全部线程 wait() 发生死锁。
也就是说,如果出现唤醒本方的情况,就可能造成死锁。因此需要唤醒所有线程,把 notify() 换成 notifyAll()。这样在第 3 步时,就唤醒了1、2、3,消费者被唤醒了就不会死锁了。
3.3 其它线程间通信方式
通过 synchronized 配合 Object 类的 wait()、notify()、notifyAll() 三个方法是属于传统的实现线程间通信的方式。相对新兴一点的方式是下一节要介绍的 Lock 搭配 Condition 的 await()、signal()、signalAll()。
此外,使用阻塞队列(BlockingQueue)也能控制线程通信。BlockingQueue 接口作为 Queue 的子接口,主要作用不是作为容器,而是作为线程同步的工具。它有一个特征:生产者线程试图向 BlockingQueue 放入元素时,如果该队列已满,则该线程被阻塞;消费者线程试图从 BlockingQueue 中取出元素时,如果该队列已空,则该线程被阻塞。
BlockingQueue 除了可以使用 Queue 中提供的方法之外,还提供了一对儿阻塞方法 put() 和 take(),对应关系如下:
最后附上 BlockingQueue 接口的继承体系:
4、Lock 接口
Lock 是在 JDK 1.5 加入的特性,它允许实现比同步代码块和同步方法更灵活的结构,并且支持多个相关的 Condition 对象。Lock 是一个接口,定义了如下方法:
使用 Lock 接口必须显式地调用 lock()/unlock() 给临界区上锁/解锁,因此它是一个显式锁
。而 synchronized 不用显式调用方法来上锁与解锁,因此 synchronized 是一个隐式锁
。
4.1 ReentrantLock
ReentrantLock 是 Lock 接口最常用的实现类,翻译过来是可重入锁,顾名思义,即一个线程可以对已被加锁的 ReentrantLock 再次加锁(而不发生死锁)。ReentrantLock 对象内部会维护一个计数器来追踪 lock() 的嵌套调用,以确保程序调用 unlock() 释放锁。ReentrantLock 的基本代码框架如下:
为了确保释放锁的动作不会因为其他代码抛出异常而不被执行,通常情况下 unlock() 要在 finally 代码块中调用,这是非常重要的一点。
4.2 Condition
Condition 接口一般被称作条件对象,主要提供了 await()、signal()、signalAll() 三个方法分别对应 Object 的 wait()、notify()、notifyAll(),它将 Object 的这三个监视器方法分解成截然不同的对象,以便通过将这些对象与任意 Lock 实现组合使用。
Lock-Condition 这套接口,与 synchronized 关键字的不同之处在于:synchronized 的锁,只能有一组属于这个锁的线程通过 wait()、notify()、notifyAll() 进行通信,而一个 Lock 上可以绑定多个 Condition,每个 Condition 里边可以用 await()、signal()、signalAll() 通信。利用这一点,可以对 3.2 节中多生产者多消费者的例子再次做出优化。
在 3.2 节中,即便是使用 notifyAll() 也是有几率唤醒本方线程的,虽然这个例子中没有造成严重的后果,但是也算是可以优化的地方。使用 Lock-Condition 可以指定唤醒对方的线程,即在 Lock 对象通过 newCondition() 生成生产者和消费者的两个 Condition 对象,那么等待/唤醒就需要指定是在哪个 Condition 上执行,获取锁的位置也要修改为等待 Lock 的哪一个 Condition:
public class ThreadCommunicationDemo4 {
static class Resource {
private int count;
// false 可写不可读,true 可读不可写
private boolean flag = false;
Lock lock = new ReentrantLock();
// 生产者条件
final Condition producerCon = lock.newCondition();
// 消费者条件
final Condition consumerCon = lock.newCondition();
public int getCount() {
lock.lock();
try {
while (!flag) {
try {
// 消费者等待
consumerCon.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
flag = false;
// 唤醒生产者,不必唤醒全部
producerCon.signal();
} finally {
lock.unlock();
}
return count;
}
public void setCount(int count) {
lock.lock();
try {
while (flag) {
try {
// 生产者等待
producerCon.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
this.count = count;
flag = true;
// 唤醒消费者,不必唤醒全部
consumerCon.signal();
} finally {
lock.unlock();
}
}
}
static class WriteThread implements Runnable {
private final Resource mResource;
public WriteThread(Resource resource) {
mResource = resource;
}
@Override
public void run() {
while (true) {
// 生产者需要拿到锁上面的“生产者条件”
synchronized (mResource.producerCon) {
int count = new Random().nextInt(100);
mResource.setCount(count);
System.out.println(Thread.currentThread().getName() + "写入数据 count = " + count);
}
}
}
}
static class ReadThread implements Runnable {
private final Resource mResource;
public ReadThread(Resource resource) {
mResource = resource;
}
@Override
public void run() {
while (true) {
// 消费者需要拿到锁上面的“消费者条件”
synchronized (mResource.consumerCon) {
System.out.println(Thread.currentThread().getName() + "读取数据 count = " + mResource.getCount());
}
}
}
}
public static void main(String[] args) {
Resource resource = new Resource();
new Thread(new WriteThread(resource)).start();
new Thread(new WriteThread(resource)).start();
new Thread(new ReadThread(resource)).start();
new Thread(new ReadThread(resource)).start();
}
}
这样就实现了分组唤醒。
4.3 synchronized 与 Lock
下面来聊聊 synchronized 与 Lock 之间的区别与联系。
synchronized 是一个隐式锁,允许每个对象有一个内部锁,该锁有一个内部条件,这使得:
- 相对简单,编写代码相对简洁
- 每个锁仅有单一的条件,可能不够
- 不能中断一个正在等待获得锁的线程
- 不能尝试拿锁,更加不能设置尝试拿锁的超时时间
synchronized 还强制要求加锁与释放锁要出现在一个块结构中,而且当获取了多个锁时,必须以相反的顺序释放,且必须在与所有锁被获取时相同的范围内释放所有锁。总结起来就是编程方便也能避免一些错误,但是不够灵活。
Lock 是一个显式锁,其实现类 ReentrantLock 是可重入锁,允许每个对象持有多个锁,并且每个锁可以有多个条件变量,有如下特点:
- 使用相对复杂,编写代码不够简洁,容易犯错
- 每个锁有多个条件,可以满足复杂的同步使用场景
- 提供了 lockInterruptibly() 可以中断等待获取锁的线程
- 提供了 tryLock() 和 tryLock(long time, TimeUnit unit),可以尝试获取锁,并设置超时时间
Lock 相比于 synchronized 更加灵活,Condition 也将监视器方法单独进行了封装,变成 Condition 监视器对象,可以任意锁进行组合。
关于所有同步工具使用的优先顺序:
- 最好既不使用 Lock-Condition 也不使用 synchronized 关键字。如果 java.util.concurrent 包下的机制能满足你的需求,应优先使用它们,如阻塞队列、并行流等。
- 如果 synchronized 适合你的程序应尽量使用它,这样可以减少代码量和出错几率。
- 当特别需要 Lock-Condition 结构提供的独有特性时,才使用它们。
4.4 锁的分类
通过前面的介绍我们也能发现,有多个角度可以对锁进行分类,比如前面已经说过的显式锁
Lock 与隐式锁
synchronized。除此之外,还有可重入锁。
可重入锁
是指某个线程已经获得某个锁,可以再次获取锁而不会出现死锁。除了前面提到的 ReentrantLock 外,synchronized 也是可重入锁。比如说:
public void synchronized test() {
count++;
test();
}
不会出现死锁,原因就是 synchronized 是可重入锁。
参考文章:
Java可重入锁详解
可重入锁详解(什么是可重入)
此外,根据执行原子操作之前还是之后获得锁这一点,可以分为乐观锁
和悲观锁
。像 CAS 这种先进行计算后进行校验的锁称为乐观锁,而像 synchronized 这种先进行校验,没有锁就不能执行的锁,称为悲观锁。
读写锁接口 ReadWriteLock 的唯一实现类 ReentrantReadWriteLock 的性能要比普通的 同步锁高很多,原因是多线程读取并不会引发线程安全问题,因此读取锁使得所有要读取的线程都能访问到共享资源并获取最新的数据(加了读锁能获取到最新,不加读锁也可拿到数据,不过不是最新的)。读写锁适用于读取请求较多的情况,下例模拟一个购物 App 访问商品数据:
// 商品 JavaBean
public class GoodsInfo {
private final String name;
private double totalMoney; //总销售额
private int storeNumber; //库存数
public GoodsInfo(String name, int totalMoney, int storeNumber) {
this.name = name;
this.totalMoney = totalMoney;
this.storeNumber = storeNumber;
}
public double getTotalMoney() {
return totalMoney;
}
public int getStoreNumber() {
return storeNumber;
}
// 卖出 sellNumber 件商品后更新库存和销售额
public void updateStoreNumber(int sellNumber) {
this.totalMoney += sellNumber * 25;
this.storeNumber -= sellNumber;
}
}
GoodsService 接口用来规定读取/写入商品数据:
public interface GoodsService {
GoodsInfo getGoodsInfo();
void setNum(int num);
}
它的两个实现类 SynService 和 RwLockService 分别使用 synchronized 同步方法和读写锁的方式实现了读写商品方法:
public class SynService implements GoodsService {
private GoodsInfo goodsInfo;
public SynService(GoodsInfo goodsInfo) {
this.goodsInfo = goodsInfo;
}
@Override
public synchronized GoodsInfo getGoodsInfo() {
try {
Thread.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
return goodsInfo;
}
@Override
public synchronized void setNum(int num) {
try {
Thread.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
goodsInfo.updateStoreNumber(num);
}
}
public class RwLockService implements GoodsService {
private GoodsInfo goodsInfo;
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private final Lock getLock = readWriteLock.readLock();
private final Lock setLock = readWriteLock.writeLock();
public RwLockService(GoodsInfo goodsInfo) {
this.goodsInfo = goodsInfo;
}
@Override
public GoodsInfo getGoodsInfo() {
getLock.lock();
try {
Thread.sleep(5);
return goodsInfo;
} catch (InterruptedException e) {
e.printStackTrace();
return goodsInfo;
} finally {
getLock.unlock();
}
}
@Override
public void setNum(int num) {
setLock.lock();
try {
Thread.sleep(5);
goodsInfo.updateStoreNumber(num);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
setLock.unlock();
}
}
}
我们还需要自定义两个线程类,一个只负责读,另一个只负责写,同时用线程对象的数量来模拟实际项目中,读请求数量大于写请求的数量:
public class BusinessApp {
private static final int readWriteRatio = 10; // 读写线程的比例
private static final int minThreadCount = 3; // 最少线程数
private static class ReadThread implements Runnable {
private GoodsService goodsService;
public ReadThread(GoodsService goodsService) {
this.goodsService = goodsService;
}
@Override
public void run() {
long start = System.currentTimeMillis();
for (int i = 0; i < 100; i++) { //操作100次
goodsService.getGoodsInfo();
}
System.out.println(Thread.currentThread().getName() + "读取商品数据耗时:"
+ (System.currentTimeMillis() - start) + "ms");
}
}
private static class WriteThread implements Runnable {
private GoodsService goodsService;
public WriteThread(GoodsService goodsService) {
this.goodsService = goodsService;
}
@Override
public void run() {
long start = System.currentTimeMillis();
for (int i = 0; i < 10; i++) { //操作10次
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
goodsService.setNum(new Random().nextInt(10));
}
System.out.println(Thread.currentThread().getName()
+ "写商品数据耗时:" + (System.currentTimeMillis() - start) + "ms---------");
}
}
public static void main(String[] args) {
GoodsInfo goodsInfo = new GoodsInfo("Goods", 100, 100);
// 内置锁
// GoodsService goodsService = new SynService(goodsInfo);
// 读写锁
GoodsService goodsService = new RwLockService(goodsInfo);
for (int i = 0; i < minThreadCount; i++) {
Thread writeThread = new Thread(new WriteThread(goodsService));
for (int j = 0; j < readWriteRatio; j++) {
Thread readThread = new Thread(new ReadThread(goodsService));
readThread.start();
}
writeThread.start();
}
}
}
main() 中通过给 GoodsService 创建两种锁实例的方式来进行对比,可以看到使用读写锁的耗时要远远小于 synchronized 的同步锁:
// 读写锁
Thread-22写商品数据耗时:636ms---------
Thread-26读取商品数据耗时:747ms
// synchronized 同步锁
Thread-2读取商品数据耗时:17050ms
Thread-11写商品数据耗时:17348ms---------
读写锁可以参考以下文章:
深入理解读写锁—ReadWriteLock源码分析
公平锁 FairSync 与非公平锁 NonFairSync 其实是 ReentrantLock 的静态内部类,并且是 final 的。创建 ReentrantLock 对象时如果在构造方法中传入 true 就会构造出一个公平锁,否则创建非公平锁:
// 默认创建非公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
公平锁与非公平锁:
- 公平锁:按照线程申请锁的顺序执行线程。哪个线程先申请的锁、等待的时间最长就先获得锁,后申请锁的线程会被暂时挂起等待。到了执行顺序后,再把该线程由挂起状态转换成可执行状态,这个上下文切换的过程是非常耗时的,大概需要20000个时间周期(1个时间周期就是执行1条语句所需的时间)。因此公平锁的效率要大大低于非公平锁,故默认情况下创建的是非公平锁。
- 非公平锁:线程的执行顺序与申请锁的顺序无关,全凭操作系统调度。synchronized 就是非公平锁。
听起来公平锁更合理一些,但是使用公平锁要比常规锁慢很多,并且即使使用公平锁也无法保证线程调度器是公平的。因此只有当你确实了解自己要做什么并且对你要解决的问题确实有一个特定的理由必须使用公平锁的时候,才可以使用公平锁。
5、死锁
死锁是指两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。
死锁的危害很大,主要有以下三方面:
- 线程不工作了,但是整个程序还是活着的
- 没有任何的异常信息可以供我们检查
- 一旦程序发生了发生了死锁,是没有任何的办法恢复的,只能重启程序,对正式已发布程序来说,这是个很严重的问题。
5.1 形成死锁的条件
同步机制使用不当可能会造成死锁,最常见的情景之一就是同步的嵌套:
public class NormalDeadLock {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
private static class RunnableA implements Runnable {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
synchronized (lock1) {
System.out.println(threadName + " got lock1");
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) {
System.out.println(threadName + " got lock2");
}
}
}
}
private static class RunnableB implements Runnable {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
synchronized (lock2) {
System.out.println(threadName + " got lock2");
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock1) {
System.out.println(threadName + " got lock1");
}
}
}
}
public static void main(String[] args) {
new Thread(new RunnableA()).start();
new Thread(new RunnableB()).start();
}
}
产生死锁的必要条件:
- 多个操作者(m>=2)争夺多个资源(n>=2),且 n<=m。
- 争夺资源的顺序不对。
- 拿到资源不放手。
学术化定义的死锁条件:
- 互斥:拿到资源后独占。
- 请求和保持:已经拿到了资源,还要请求新的资源。
- 不剥夺:线程已经拿到的资源,在使用完成前不能被剥夺,只能在使用完后自己释放。
- 环路等待:线程 0 拿了 A 锁请求 B 锁,而线程 1 拿了 B 锁请求 A 锁。
5.2 避免死锁的方法
避免死锁要从破坏死锁的必要条件上入手:
- 打破互斥条件:改造独占性资源为虚拟资源,大部分资源已无法改造。
- 打破不可抢占条件:当一线程占有一独占性资源后又申请一独占性资源而无法满足,则退出原占有的资源。
- 打破占有且申请条件:采用资源预先分配策略,即线程运行前申请全部资源,满足则运行,不然就等待,这样就不会占有且申请。
- 打破循环等待条件:实现资源有序分配策略,对所有设备实现分类编号,所有线程只能采用按序号递增的形式申请资源。
破坏以上任一条件都可以打破死锁。以上面的代码举例来说,从“打破循环等待条件”这一条出发,可以让两个线程都先争夺同一个锁,而不是一个先争夺 lock1 另一个先争夺 lock2;
从“打破不可抢占条件”这一条出发,可以使用 Lock 锁的“尝试拿锁”机制代替 synchronized 锁:
public class TryLock {
private static final Lock lock1 = new ReentrantLock();
private static final Lock lock2 = new ReentrantLock();
private static class RunnableA implements Runnable {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
boolean flagLock2 = false;
while (true) {
if (lock1.tryLock()) {
try {
System.out.println(threadName + " got lock1");
if (lock2.tryLock()) {
flagLock2 = true;
try {
System.out.println(threadName + " got lock2");
System.out.println(threadName + " do work---");
break;
} finally {
lock2.unlock();
}
}
if (!flagLock2) {
System.out.println(threadName + " didn't get lock2.Release lock1.");
}
} finally {
lock1.unlock();
}
}
try {
Thread.sleep(new Random().nextInt(3));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
private static class RunnableB implements Runnable {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
boolean flagLock1 = false;
while (true) {
if (lock2.tryLock()) {
try {
System.out.println(threadName + " got lock2");
if (lock1.tryLock()) {
flagLock1 = true;
try {
System.out.println(threadName + " got lock1");
System.out.println(threadName + " do work---");
break;
} finally {
lock1.unlock();
}
}
if (!flagLock1) {
System.out.println(threadName + " didn't get lock1.Release lock2.");
}
} finally {
lock2.unlock();
}
}
try {
Thread.sleep(new Random().nextInt(3));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) {
new Thread(new TryLock.RunnableA()).start();
new Thread(new TryLock.RunnableB()).start();
}
}
Lock 的 tryLock() 返回 true 时表明已经拿到锁,返回 false 表示没有拿到锁。对于两个线程来说,如果只拿到了外层的锁,而没有拿到内层的锁,那么通过在外层的 finally 块中释放外层锁,可以避免死锁。通过死循环与拿到两道锁执行完任务后 break 的搭配可以保证任务一定会被执行一次。一种可能的输出如下:
// Thread-0 先拿到 lock1,但是没拿到 lock2,那么就释放 lock1
Thread-0 got lock1
Thread-0 didn't get lock2.Release lock1
// Thread-1 得到执行权,先后拿到 lock2、lock1
Thread-1 got lock2
Thread-1 got lock1
Thread-1 do work---
// Thread-1 执行完任务将两道锁释放,最后 Thread-0 拿到两道锁执行完任务
Thread-0 got lock1
Thread-0 got lock2
Thread-0 do work---
避免死锁常见的算法有有序资源分配法、银行家算法,这里我们就不详细展开了。
死锁与程序阻塞并不是相同的概念。死锁是所有线程都在等待对方释放锁,而阻塞并不是因为争抢不到同步锁,只是所有线程都处于阻塞状态无法被唤醒去继续执行!
5.3 活锁
上例中 RunnableA 和 RunnableB 的 run() 的最后都使用了 Thread.sleep() 使当前线程进行等待,如果把它们去掉会得到如下的结果:
Thread-0 got lock1
Thread-1 got lock2
Thread-0 didn't get lock2.Release lock1.
Thread-1 didn't get lock1.Release lock2.
Thread-0 got lock1
Thread-1 got lock2
Thread-0 didn't get lock2.Release lock1.
Thread-1 didn't get lock1.Release lock2.
Thread-0 got lock1
Thread-1 got lock2
Thread-0 didn't get lock2.Release lock1.
Thread-1 didn't get lock1.Release lock2.
Thread-0 got lock1
Thread-1 got lock2
Thread-0 didn't get lock2.Release lock1.
Thread-1 didn't get lock1.Release lock2.
…… 省略n多次重复
Thread-0 got lock1
Thread-0 got lock2
Thread-0 do work---
Thread-1 got lock2
Thread-1 got lock1
Thread-1 do work---
可见两个线程拿锁的过程急剧加长了,多次出现了 Thread-0 拿到 lock1,Thread-1 拿到 lock2,然后两个线程拿第二个锁失败的情况,后续又重复了多次这种情况。像这种线程仍处于运行状态,但实际上却没有执行业务代码(一直在做拿锁->释放锁的无用功)的情况,称为活锁。
解决方式就是在锁的范围之外使用 Thread.sleep() 休眠一小段时间,让两个线程拿锁的时间错开一点,进而避免双方各拿到一个锁的局面发生。
Thread.sleep() 会让出 CPU,但是不会释放锁。如果把休眠动作放在锁内,由于本方线程已经拿到的锁并不会释放,即使让出 CPU 时间片给对方线程,对方线程也是拿不到本方已经持有的锁的。所以上边才强调要在锁的范围之外加 Thread.sleep()。
另外,Thread.sleep() 不会释放锁,但是 Object.wait() 会释放锁。调用了前者的线程,时间到了会自动唤醒,而调用了后者的线程会进入线程等待池中等待,只有通过 notify()、notifyAll() 等方法唤醒后,才能重新进入就绪队列抢锁执行。
6、线程安全集合
使用 Collections 工具类可以把线程不安全的 ArrayList、HashSet、HashMap 等集合变成线程安全集合:
除此之外,在 java.util.concurrent 包下有大量支持高效并发访问的集合接口和实现类:
可以分为如下两类:
详细介绍:
7、测试题
1.run 和start的区别 ?
答:run是函数调用 和线程没有任何关系, .start会走底层 会走系统层 最终调度到 run函数,这才是线程。
2.如何控制线程的执行顺序 ?
答:join来控制 让t2获取执行权力,能够做到顺序执行
3.多线程中的并行和并发是什么?
答:四个车道,四辆车并行的走,就是并行, 四个车道中,五秒钟多少的车流量,多少的吞吐量一样
4.在Java中能不能指定CPU去执行某个线程?
答:不能,Java是做不到的,唯一能够去干预的就是C语言调用内核的API去指定才行,这个你回答的话,面试官会觉得你研究点东西
5.在项目开发过程中,你会考虑Java线程优先级吗?
答:不会考虑优先级,为什么呢? 因为线程的优先级很依赖与系统的平台,所以这个优先级无法对号入座,无法做到你想象中的优先级,属于不稳定,有风险
因为某些开源框架,也不可能依靠线程优先级来,设置自己想要的优先级顺序,这个是不可靠的
例如:Java线程优先级又十级,而此时操作系统优先级只有2~3级,那么就对应不上
6.sleep和wait又什么区别?
答:sleep是休眠,等休眠时间一过,才有执行权的资格,注意:只是又有资格了,并不代表马上就会被执行,什么时候又执行起来,取决于操作系统调度
wait是等待,需要人家来唤醒,唤醒后,才有执行权的资格,注意:只是又有资格了,并不代表马上就会被执行,什么时候又执行起来,取决于操作系统调度
含义的不同:sleep无条件可以休眠, wait是某些原因与条件需要等待一下(资源不满足,拿不到同步锁就等待)
7.在Java中能不能强制中断线程的执行?
答:虽然提供了 stop 等函数,但是此函数不推荐使用,为什么因为这种暴力的方式,很危险,例如:下载图片5kb,只下载了4kb 等
我们可以使用interrupt来处理线程的停止,但是注意interrupt只是协作式的方式,并不能绝对保证中断,并不是抢占式的
8.如何让出当前线程的执行权?
答:yield方法,只在JDK某些实现才能看到,是让出执行权
9.sleep,wait,到底那个函数才会 清除中断标记?
答:sleep在抛出异常的时候,捕获异常之前,就已经清除
10.如果错误 错误发生在哪一行?
class Test implements Runnable {
public void run(Thread t) {}
}
错误在第一行,应该被 abstract 修饰。其实是 Runnable 接口中的 run() 方法是无参的,而例子中的 run(Thread t) 以 Thread 对象作为参数,其实就是没有实现 Runnable 接口,所以这个类要声明成抽象类。
11.运行结果
new Thread(new Runnable()
{
public void run()
{
System.out.println("runnable run");
}
})
{
public void run()
{
System.out.println("subThread run");
}
}.start();
subThread run。重写了 run 方法就以它为主,没有重写就以 Runnable 接口为主。