目录
1、线程是否安全
线程不安全就是一些代码在多线程的运行状态下,达不到预期的运行效果出现bug。如果在多线程的各种随机调度上,代码都没有bug,能以预期的结果运行那么该线程就是安全的。
2、出现线程安全的原因如下:
1、线程之间的抢占式执行,对线程的调度过程是随机的。
2、多个线程同时修改一个变量,并且修改的操作不是原子的
3、内存可见性问题
4、执行的重排序
3、原子性问题
原子性就是一个行为,要么执行完毕,要么就不执行。或者是执行到一半中断的话,能够恢复如初。例如我们去银行转账操作,扣款,转账两个操作必须是一起完成的。不然的话可能就会造成一边扣款了,另外一张卡却没有打上钱。所以有些操作必须是原子性的,也就是要么不执行,执行的话就执行到底。不能中断,中断的话就恢复如初。
有些Java语句看上去是一个语句一个操作。但是可能在CPU执行的时候就被拆成了好几个操作执行,这在我们多线程执行中就会出现bug,这样的代码就是线程不安全。
例如:count++;该操作就会在CPU执行时分成三个步骤,分别是加载进入CPU(load)、自增(add)、加载出CPU(save)。当我们执行下列代码时就会出现线程安全问题。
public class Demo5 {
public static void increase(){
sum++;
}
public static int sum=0;
public static void main(String[] args) throws InterruptedException {
Thread t1 =new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
increase();
}
}
});
Thread t2 =new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
increase();;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(sum);
}
}
两个线程各执行了自增5w次的操作,结果却如下:
因为我们线程之间的调度是完全随机的,所以就有可能发生下列情况,此时看似两个线程都执行了自增的操作,但是count却是只增加了1。线程1在进行自增的时候count变量已经被线程2取出,所以只加了1。
为了解决这样的问题我们就需要给自增这个操作加上锁。加上锁之后自增这三个操作就变成了原子性的。不可拆开执行,也就是线程1再执行这三个操作的时候,线程2处于阻塞状态等待获取锁,当线程1执行完毕这三个操作的时候,才会释放掉锁。此时若没有其他线程等待获取锁那么就是线程2拿到锁并且执行自增操作。操作如下:
1
值得注意的是,如果此时有多个线程等待获取锁,那么谁拿到锁这个行为也是随机的,如图所示
此时是谁进去面试就是一个随机情况,此时多个面试者去竞争面试机会,就是一个典型的锁竞争。
如何加锁?
4、synchronized关键字
我们一般使用synchronized关键字进行加锁操作。
1、锁对象
在Java中,每一个对象都拥有一个锁标记(monitor),也称为监视器,多线程同时访问某个对象时,线程只有获取了该对象的锁才能访问。
看到有些博客上面写的对象锁、类锁,方法锁,这种说法其实不太严谨,其实只有一种锁,也就是可以把方法锁和类锁,都归为对象锁。方法锁的锁对象是this对象,类锁锁的是该静态方法的class对象。这也对应了synchronized的三种用法
2、用法:
1、修饰方法
修饰方法也就是针对this对象加锁,在方法前加上synchronized即可加锁。
public class Demo5 {
public static synchronized void increase(){
sum++;
}
public static int sum=0;
public static void main(String[] args) throws InterruptedException {
Thread t1 =new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
increase();
}
}
});
Thread t2 =new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
increase();;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(sum);
}
}
此时,结果就是100000了。
2、修饰代码块
此时我们要明确锁上哪一段的代码,在代码设计时多锁和少锁代码都可能导致代码线程不安全。其次就是明确加锁对象,如果针对两个不同的对象加锁那么是不会发生锁竞争的。
class Counter{
public int sum=0;
public void increase(){
synchronized (this){
sum++;
}
}
}
public class Demo6 {
public static void main(String[] args) throws InterruptedException {
Counter counter=new Counter();
Thread t1 =new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
}
});
Thread t2 =new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.sum);
}
}
3、修饰静态方法
修饰静态的方法其实就是修饰该类的class对象,如下
class Counter4 {
public static int sum = 0;
public static void increase() {
synchronized (Counter4.class) {
sum++;
}
}
}
3、可重入锁
假如我们有一个线程,给自己上了锁,但是由于代码逻辑实现,导致该线程有个自己上了一次锁。但是在上锁的时候,我们要先获取到锁,所以此时该线程就进入阻塞状态,不再执行。但是由于该线程已经上锁,又无法进行解锁操作,而且只有该线程能解开这把锁。此时就陷入了死锁。
但是我们的synchronized是一个可重入锁,因此不会发生以上的问题。
因为:Java的synchronized实现了两个功能
1、记录下哪个线程加了锁。
当该线程第二次加锁时就检测到,该线程已经加过锁了,此时就直接放行不再加锁
2、维护一个计数器。
用来衡量什么时候时真的加锁,什么时候该真的解锁,啥时候该放行
5、内存可见性
此时我们只是解决了原子性问题,但是还是有其他的可能导致线程不安全
什么是内存可见性问题?
public class Demo13 {
static class Counter {
public int count = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
while (counter.count == 0) {
}
System.out.println("t1 执行结束. ");
});
t1.start();
Thread t2 = new Thread(() -> {
System.out.println("请输入一个 int: ");
Scanner scanner = new Scanner(System.in);
counter.count = scanner.nextInt();
});
t2.start();
}
}
阅读以上代码,也就是我们在t2线程中把count改为一个非0的值,我们的t1线程中的while就会停止循环(循环条件是count==0)。但是当我们运行时发现t1没有结束。
因为我们的判断count==0的操作,其实时两个操作读取内存load,比较cmp。但是load操作要比cmp操作慢的多,所以我们的编译器会进行一些优化,也就是只读一次内存,虽然效率提升了但是却带来了这样的问题。
6、volatile关键字
volatile关键字就可以解决上述问题,我们使用volatile修饰一个变量,就可以使该变量不再进行优化,保证了内存可见性。
值得注意的是,volatile只是解决了内存可见性问题,但是并没有解决原子性问题,也就是如果有两个以上的线程同时修改我们的count变量时还是线程不安全,所以还要加上synchronized关键字。
7、JMM
还是内存可见性问题,如果我们在硬件层面看内存可见性问题,其实是编译器优化导致只读了寄存器,不去读内存,因为寄存器的读取速度要比内存快得多。
Java为了让上述说法更加严谨引入了新的术语JMM(Java Memory Model)用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。
正常一个程序执行过程中,会把主内存的数据,先加载到工作内存中,在进行计算处理,编译器优化可能会导致不是每次都真的读取主内存,而是直接去工作内存中的缓存数据(可能导致内存可见性)volatile可以保证每次读取内存都是从主内存读取。