在Java中的线程常用方法这篇文章中,我们简单介绍了Java中部分关于线程常用的方法,对线程的创建和使用也有了一定的了解。下面我们就延伸出多线程最为重要的一个问题——线程安全。
1. 线程不安全
下面这段代码中,我们使用for循环启动了10个线程,每一个线程都对类中的静态变量进行10000次自增操作,按照正常的逻辑来说,等到10个线程都执行完他们的任务之后,COUNT 这个变量应该是被自增了10万次,所以COUNT应该为100000。
public class UnsafeThread {
public static int COUNT;//处于方法区
public static void main(String[] args) throws InterruptedException {
//开启20个线程,每个线程COUNT自增一万次
//多个线程对同一个共享资源进行
for (int i = 0; i < 20; i++) {
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
COUNT++;
}
}
}).start();
}
while (Thread.activeCount() > 1) {
//当线程都没有执行完时,main主线程一直等待
Thread.yield();
}
System.out.println(COUNT);
}
}
运行这段代码,我们得到的COUNT是一个小于100000的数字,并且在运行了很多次之后,发现每次COUNT都是一个不同的数字,并且都是小于10万的数字。我们可以看到,在打印之前我们特意加了一个循环条件,这个while循环的意思是,正在存活的线程数大于1的时候,这个代码行就要让出CPU给其他线程使用,也就是main主线程到这段代码行要让出CPU,主要是为了确保我们能够在是个线程运行结束后再去打印这个静态变量。
但即使使用了 yield 方法让main主线程让出了CPU,答案怎么仍然没有达到我们的预期。这就是多线程运行环境下带来的线程不安全问题。
2. 线程不安全的原因
线程工作原理(原子性和可见性)
我们知道多线程能为我们带来很高效的执行速度,但是线程是怎样完成CPU的工作的呢?
我们根据刚才的代码可以看到,每一个线程都在对COUNT进行一个+1的操作,COUNT这个静态变量是存放在主内存中的,线程在执行操作时,要从主内存把这个COUNT变量读取到CPU的工作内存中来,这个读取实际上是一个拷贝操作,将拷贝过来的数据在自己的工作内存中进行处理,在处理完以后,再把这个处理过的副本重新写回原来的主内存。这一系列操作,就会改变原来主内存中COUNT变量的值。这也是我们线程不安全的一个重要因素,由于这个拷贝、改值和写回三个操作,就影响到了多线程的原子性。试想,既然是非原子性操作,同时多线程又是并发执行的时候,那么如果当一个线程执行到其中一个操作的时候,另外一个线程中途跑过来进行了拷贝或者写回主内存等任一操作时,这种情况就会直接导致运行结果的错误。
举个例子:比如我们十个线程正在对COUNT变量进行自增操作。假设现在刚开始启动线程,此时COUNT的值为0。如果现在一号线程刚把COUNT的值0拷贝到自己的工作内存中,开始对这个0进行+1的操作。这时,二号线程闲不住了,也要对COUNT进行+1操作,但是一号线程这时正在对自己工作内存的数值进行增加,还没有将增加后的值写回主内存。所以二号线程并没有在一号线程的基础上操作,而是又把0拷贝到自己的工作内存中。试想,两个线程都是拷贝的0,那么即使他们都完成了自己的工作,一号线程把10000写回到了主内存后,二号线程工作完也把0增加后的10000写回主内存,此时主内存的COUNT在经过两个线程的操作之后,只产生了一次的效果,还是10000。这就是多线程带来的不安全问题。
这个例子中出现了两个线程安全的特性:原子性和可见性。原子性刚才提到了,可见性表示每个线程对其他线程做出的改变是有察觉和回应的,但是在刚才假设的情况中,JVM为了提高效率,会尽可能的让数据在工作内存中执行,但是一号线程在自己的工作内存改变数据的时候,其他线程是无法看到主内存共享数据的改变的,这就是可见性问题。
代码重排序问题
在单线程情况下,为了节省时间消耗,JVM 和 CPU 会对代码操作执行顺序重新排序以达到最优,来提高执行效率。但如果是多线程的话,因为非原子性,如果重排序,会让多个线程在执行同一类任务时无法协调平衡,也就无法保证线程的安全性。
3. 怎样解决线程不安全问题?
synchronized 关键字
我们通常将使用了 synchronized 关键字的线程形象地称之为加锁。
特点
为什么要是用 synchronized 关键字加锁来解决线程不安全问题呢?
- 首先当线程获取锁时,JMM(java内存模型)会把该线程对应的本地内存置为无效。从而使得获取锁的线程必须要从主内存中去读取共享资源。此时,synchronized 同步代码块在获取到锁的线程执行完毕之前,阻塞其他的线程,不会别的线程再进入该代码块。
- 当线程释放锁时,JMM会把该线程对应的工作内存中的共享变量刷新到主内存中。
同步队列
我们知道只有获取了锁的线程才能进入同步代码块,但当锁被其中一个线程获取之后,其他线程就都被阻塞,只能等待获取到锁的线程执行完再释放锁,当锁被释放后,所有的线程都开始竞争这把锁,此时还是只能有一个“幸运儿”竞争到锁,其他竞争失败的线程就会进入同步队列之中。
使用方法
- 静态同步方法:锁定的是当前类的对象
public static synchronized method(){}
- 同步实例方法:锁定当前对象(this). 谁调用就锁定谁.
public synchronized method(){}
- 同步代码块,锁定的是obj对象. 参数传入谁就锁定谁.
synchronized(Object o) {}
了解了 synchronized 关键之后,我们就可以使用它来完成我们之前失败的代码:
public class UnsafeThread {
public static int COUNT;//处于方法区
public static void main(String[] args) throws InterruptedException {
//开启20个线程,每个线程COUNT自增一万次
//多个线程对同一个共享资源进行
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (UnsafeThread.class) {
for (int i = 0; i < 10000; i++) {
COUNT++;
}
}
}
}).start();
}
while (Thread.activeCount() > 1) {
//当线程都没有执行完时,main主线程一直等待
Thread.yield();
}
System.out.println(COUNT);
}
}
我们在覆写的run方法内加入了 synchronized 同步代码块,并且将对象设定为当前的主类。这段优化以后的代码,最终运行的COUNT打印出来的值就是我们想要的100000。
volatile 关键字
修饰的共享变量,可以保证可见性,部分保证顺序.通常用来保证共享变量的读取操作安全。他能够保证共享变量的有序性和可见性。
作用
- 禁止指令重排序, new 操作会进行指令分解,但不再重排序)
- 可见性:可见性得到保证,都是从主内存提取。
- 修饰的变量所在代码行建立内存屏障。保证代码行前不会被排到后面执行,后面的代码不会被排到前面执行。
4. wait()、notify()和notifyAll()
这三个方法使我们在使用多线程锁时要经常使用到的。分别表示等待操作、唤醒一个线程和唤醒所有线程。其中唤醒方法会通知 wait() 阻塞的线程和 synchronized 造成阻塞的线程一起竞争同一个对象的锁。
作用
- wait() : 对锁了的对象进行等待操作。等待唤醒或时间参数结束。
- notify() :唤醒调用了对象的锁,由其他线程竞争这个锁,由JVM随机选择一个来通知。
- notifyAll() : 唤醒所有线程来竞争锁。