线程安全是指在多线程环境中,一个类或者方法能够保证在任意时刻,无论在哪个线程中调用,都能表现出一致的行为,且不会对其他线程产生不可预测的影响.
相反我们则称为存在线程安全问题或者线程不安全
看了上面的解释大家可能还不理解,下面我们通过一个典型的线程不安全示例
static int count=0;
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
for (int i = 0; i < 1_0000; i++) {
count++;
}
});
Thread t2=new Thread(()->{
for (int i = 0; i < 1_0000; i++) {
count++;
}
});
t1.start();
t2.start();
//保证两个线程都执行完
t1.join();
t2.join();
System.out.println(count);
}
如果是在单线程中,我们都知道结果是20000,但是在多线程中就不一样了
多运行几次,我们发现每次的结果都不一样,这就是因为两个线程存在线程安全问题,会相互影响
这里就和汇编指令有关系了,在CPU中一个简单的count++
,实际要执行3个指令,我们这里简单分为3步:
count
的值,加载到寄存器上count
的值加1根据前面的学习,我们都知道线程是随机调度的,我们也不知道什么时候是执行哪个线程
因为两个线程都是对count
进行操作,因此两者都要执行这三个指令,就会产生很多种搭配
例如:
t1线程先执行load
指令后被调度走,(我们假设是刚开始)这时寄存器加载的是0;t2执行完3个指令再被调度走,这时count
的值变成1,但是t1已经执行了load
指令拿到了0时刻的值,再到t1执行的时候把剩下两个指令执行完,我们发现count
应该会变成2,结果变成了1
根据上面的例子,我们可以联想到会有很多种情况,因此我们说上面代码是线程不安全的
根本原因是线随机调度
原子性是指一个操作或者一系列操作不可再分,要执行就要全部执行完.
例如典型示例中的count++
操作就不是符合原子性的
但count=1
赋值操作是符合原子性的
可见性是指当多个线程都会访问同一个变量,一个线程对该变量进行更改,其他线程都能立即得到修改后的值.
但如果其他线程还是使用该变量未更改之前的值,这类问题就是内存可见性问题
我们来下面的典型示例
static boolean key=true;
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
while (key) {
}
System.out.println("t1,结束了");
});
Thread t2=new Thread(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
key=false;
System.out.println("t2,结束了");
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("main,结束了");
}
如果不看运行结果,相信大家都会认为是分别打印t2,t1,main
就结束了
但运行结果是死循环了
这是因为优化导致的,t1线程在多次获取key的值都是相同的,就把key的值放到寄存器上,从内存中获取变成之间从寄存器中取就行,这样可以省下不少的开销,但是也因此t2线程虽然对key的值进行修改,但是t1线程并没有从内存中重新获取key的值,而是一直使用旧的数据导致t1线程死循环
指令重排序是指一个操作不是原子性的情况下,JVM可能会进行优化,导致指令不是顺序执行的
例如我们去买水果,你要买西瓜,草莓,苹果,雪梨,我们可以根据路线(远近)优化成先买草莓,再买雪梨,接着买西瓜,最后买苹果
关于指令重排序问题,后面我会以单例模式中的双重检测懒汉模式为例进行讲解
使用
synchronized
关键字可以给对象加锁,如果其他线程访问到锁对象则会阻塞,等到锁对象解锁了才可以使用
锁对象是什么不重要,重要的是是否相同
进⼊synchronized
修饰的代码块,相当于加锁
退出synchronized
修饰的代码块,相当于解锁
使用synchronized
关键字可以把一系列操作变成原子性
通过synchronized
关键字可以解决count++
的问题
static int count=0;
public static void main(String[] args) throws InterruptedException {
Object object=new Object();
Thread t1=new Thread(()->{
synchronized (object) {
for (int i = 0; i < 100_0000; i++) {
count++;
}
System.out.println("t1,执行完毕");
}
});
Thread t2=new Thread(()->{
synchronized (object){
for (int i = 0; i < 100_0000; i++) {
count++;
}
System.out.println("t2,执行完毕");
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
如果synchronized
关键字修饰普通方法则是对当前对象加锁
public synchronized void addCount() {
count++;
}
如果synchronized
关键字修饰静态方法则是对类对象加锁
public static synchronized void addCount() {
count++;
}
在java中,如果一个线程对同一个对象加锁多次,并不会导致阻塞,而是只当成一把锁
如果我们把t1线程变成下面这样是不影响使用的
Thread t1=new Thread(()->{
synchronized (object) {
synchronized (object){
for (int i = 0; i < 100_0000; i++) {
count++;
}
System.out.println("t1,执行完毕");
}
}
});
使用
volatile
关键字能保证内存可见性,不能保证原子性
volatile
关键字作用如下:
volatile
关键字修饰的变量值修改都会存入内存volatile
关键字修饰的变量读取值都是从内存中读取,保证数据的准确性我们通过volatile
关键字来解决上面内存可见性的问题
volatile static boolean key=true;
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
while (key) {
}
System.out.println("t1,结束了");
});
Thread t2=new Thread(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
key=false;
System.out.println("t2,结束了");
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("main,结束了");
}
但是如果我们用volatile
关键字能不能解决一开始count++
的问题呢?
我们来试一下
volatile static int count=0;
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
for (int i = 0; i < 100_0000; i++) {
count++;
}
});
Thread t2=new Thread(()->{
for (int i = 0; i < 100_0000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
显然是不行的,因此volatile
关键字不能保证原子性