实现多线程安全的3种方式
1、先来了解一下:为什么多线程并发是不安全的?
在操作系统中,线程是不拥有资源的,进程是拥有资源的。而线程是由进程创建的,一个进程可以创建多个线程,这些线程共享着进程中的资源。所以,当线程一起并发运行时,同时对一个数据进行修改,就可能会造成数据的不一致性,看下面的例子:
假设一个简单的int字段被定义和初始化:
int counter = 0;
该counter字段在两个线程A和B之间共享。假设线程A、线程B同时对counter进行计算,递增运算:
counter ++;
那么计算的结果应该是 2 。但是真实的结果却是 1 ,这是因为:线程A得到的运算结果是1,线程B的运算结果也是1,当线程A将结果写回到内存中的 count 后,线程B也将结果写回到内存中去,这就会把线程A的计算结果给覆盖了。
上面仅仅是一种简单的情况,还有更复杂的情况,本文不深入去了解。
2、多线程并发不安全的原因已经知道,那么针对这个种情况,java中有两种解决思路:
- 给共享的资源加把锁,保证每个资源变量每时每刻至多被一个线程占用。
- 让线程也拥有资源,不用去共享进程中的资源。
3、基于上面的两种思路,下面便是3种实施方案:
1. 多实例、或者是多副本(ThreadLocal):对应着思路2,ThreadLocal可以为每个线程的维护一个私有的本地变量,可参考java线程副本–ThreadLocal;
2. 使用锁机制 synchronize、lock方式:为资源加锁,可参考我写的一系列文章;
3. 使用 java.util.concurrent 下面的类库:有JDK提供的线程安全的集合类
可能说的还不太清楚,更新一下,以及给出一个线程安全模拟的例子:
上面说了,多线程之所以不安全,是因为共享着资源(如果没有资源变量共享,那么多线程一定是安全的)。比如,存在共享变量a,线程A在使用变量a时进行计算时,因为时间片的到来,导致线程不得不由运行中状态进入就绪状态,暂停运行。等该线程A又重新被调度,得以继续执行时,得到了最终的结果。但是此时内存中的变量a可能已经被其他线程改变了,但线程A的结果再写回到内存中时,就会覆盖了其他线程的计算结果,这就是多线程不安全的原理。
下面给出线程安全模拟的例子的思路:1、让三个线程瞬间同时并发(不得不用到锁,wait/notify机制,如果不懂,只要知道这是 等待/通知 便可,下面有注释);2、模拟3个线程共享着一个变量,使用变量进行计算的过程 与 将计算结果分成两次执行。
下面是没有进行同步,也就是线程不安全的情况:
CountMoney countMoney = new CountMoney();
String obj="";
//创建启动3个线程
for(int i=0;i<3;i++){
Thread t1 = new Thread(){
@Override
public void run() {
//用锁来让线程第一次运行时,进入等待状态,直到被通知来了才继续往下运行
synchronized (obj) {
try {
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//通知来了后,执行addMoney的方法
countMoney .addMoney(1);
}
};
//线程启动
t1.start();
//确保创建的线程的优先级一样
t1.setPriority(Thread.NORM_PRIORITY);
}
try {
//确保创建的3个线程已经运行了一次,进入等待状态
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (obj) {
//瞬间唤醒3个线程
obj.notifyAll();
}
CountMoney 类
public class CountMoney {
//线程共享着CountMoney对象中的money变量
volatile long money = 0;
public long getMoney() {
return money;
}
public void setMoney(long money) {
this.money = money;
}
public void addMoney(long a) {//synchronized
//1、取得变量money的值,计算出结果
a = getMoney() + a;
//线程完成第一步后,让出CPU;目的是:模拟1、2两行代码是分成两次执行的,不是一次性执行的
Thread.yield();
//2、将计算结果写回到变量money中
setMoney(a);
System.out.println("线程"+Thread.currentThread().getName()+"的计算结果"+getMoney());
}
}
运行结果:
线程Thread-2的计算结果1
线程Thread-1的计算结果1
线程Thread-0的计算结果1
我们再来看一下,加锁后的 addMoney()方法,也就是进行同步后:
public synchronized void addMoney(long a) {//加了synchronized 修饰
//1、取得变量money的值,计算出结果
a = getMoney() + a;
//线程完成第一步后,让出CPU;目的是:模拟1、2两行代码是分成两次执行的,不是一次性执行的
Thread.yield();
//2、将计算结果写回到变量money中
setMoney(a);
System.out.println("线程"+Thread.currentThread().getName()+"的计算结果"+getMoney());
}
运行结果:
线程Thread-2的计算结果1
线程Thread-1的计算结果2
线程Thread-0的计算结果3
锁和同步
常用的保证Java操作原子性的工具是锁和同步方法(或者同步代码块)。使用锁,可以保证同一时间只有一个线程能拿到锁,也就保证了同一时间只有一个线程能执行申请锁和释放锁之间的代码。
public void testLock () {
lock.lock();
try{
int j = i;
i = j + 1;
} finally {
lock.unlock();
}
}
与锁类似的是同步方法或者同步代码块。使用非静态同步方法时,锁住的是当前实例;使用静态同步方法时,锁住的是该类的Class对象;使用静态代码块时,锁住的是synchronized
关键字后面括号内的对象。下面是同步代码块示例
public void testLock () {
synchronized (anyObject){
int j = i;
i = j + 1;
}
}
无论使用锁还是synchronized,本质都是一样,通过锁来实现资源的排它性,从而实际目标代码段同一时间只会被一个线程执行,进而保证了目标代码段的原子性。这是一种以牺牲性能为代价的方法。