线程安全 1_线程安全
目录
1、线程的状态
2、线程不安全代码案例
3、线程安全的概念
4、线程不安全的原因
线程不安全的原因总结
5、解决线程不安全问题
6、通过synchronized 关键字进行加锁
锁的特性:
1、互斥、排他
2、可重入
解决刚才的线程不安全案例
7、对锁的补充
1、线程的状态
我们之前讲过进程有两种状态:
就绪:当前进程随时可以去CPU上参与执行调度(也包括在CPU上执行)
阻塞:当前进程暂时不方便去CPU上执行。
在Java中,线程也有不同的状态:
1、NEW:Thread对象创建好了,但是还没有调用start方法在系统中创建线程。
2、TERMINATED:Thread对象任然存在,但是系统内部的线程已经执行完毕了。
3、RUNNABLE:就绪状态,表示这个线程正在CPU上执行,或者随时准备就绪可以去CPU上执行。
4、TIME_WAITING:指定时间的阻塞,即到达一定时间之后,自动解除阻塞,使用sleep方法和带时间参数的join方法都会进入这个状态。
5、WAITING:不带时间的阻塞(死等),必须满足一定条件才会解除阻塞。不带时间的jion和wait都会进入该状态。
6、BLOCKED:由于锁竞争引起的阻塞。
代码示例:
public class ThreadDemo18 {public static void main(String[] args) throws InterruptedException {Thread thread = new Thread(()->{for (int i = 0; i < 5; i++) {System.out.println("线程运行中……");try {Thread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}}});//线程启动之前就是NEW状态System.out.println(thread.getState());thread.start();Thread.sleep(500);//此时线程仍在sleepSystem.out.println(thread.getState());thread.join();//线程运行结束后状态就是TERMINATEDSystem.out.println(thread.getState());}
}
运行结果:
总结:此外,如果发现某个进程卡住了,我们可以使用jconsole,查看这个进程中线程的状态和调用栈,通过状态,就可以看到当前线程是否阻塞,是因为什么原因阻塞的。
2、线程不安全代码案例
假设此时我们有一个任务,需要对一个计数器count++十万次,这时候我们就想一个线程对count进行++太慢了能不能使用多个线程对count进行++呢?
public class ThreadDemo19{//存在线程安全问题的代码private static int count = 0;private static int count2 = 0;public static void main(String[] args) throws InterruptedException {Object locker = new Object();Thread t1 = new Thread(()->{for (int i = 0; i < 50000; i++) {count++; }});Thread t2 = new Thread(()->{for (int i = 0; i < 50000; i++) {count++;}});t1.start();t2.start();//等待t1、t2结束后再进行打印t1.join();t2.join();System.out.println("count = "+ count);}
}
运行代码发现结果不符合预期,且每次运行的结果都各不相同 :
3、线程安全的概念
向上面这样,代码在单线程下能够运行正确,但是在多线程下,可能产生bug。这个情况就称为“线程不安全”或者“存在线程安全问题”。
反之,某个代码在无论在单线程下执行,还是在多线程下执行,都不会产生bug。这个情况称为“线程安全”。
4、线程不安全的原因
上述count++代码其实是由三个CPU指令构成的:
1、load:将数据从内存读取到CPU寄存器中。(这里最好称为工作内存,因为也有可能读取到缓存中,工作内存:CPU寄存器+缓存)。
2、add:把寄存器中的值+1。
3、save:把寄存器中的数据写回内存中。
过程如下图所示:
如果是一个线程执行上述的三个指令,当然是没问题的,但如果是两个线程,并发实行上述操作,此时就会存在很多变数,这是因为:线程的调度顺序是不确定的!!!
以下是列举的一些情况:
这里一共有多少种情况呢?无数种。有可能t1执行一次++时,t2执行两次++,t1执行一次++时,t2执行三次++,t1执行一次++时,t2执行N次++或者t1执行N次++,t2只执行1次++……此时的排列组合,又会有很多种,所以会有无数种情况。
作为程序员,我们需要保证:上述无论什么执行顺序执行代码,得到的结果都是正确的。
我们需要分析清楚,什么样的执行顺序下执行结果是正确的,什么顺序下结果是错误的:通过分析,我们发现第一和第二种执行顺序,所得到的结果是没有问题的。
由于这两个线程是并行执行/并发执行并不确定,但是即使是并发执行,在一个CPU核心上,两个线程有各自的上下文(各自一套寄存器的值,并不会互相影响)。
所以,最关键的问题是,确保一个线程save之后,另一个线程再进行load。这个时候第二个线程load得到的才是第一个线程++后的结果,否则,第二个线程load得到的就是第一个线程++前的结果,所以两次++,可能实际就值++了1次。
线程不安全的原因总结
1、根本原因:操作系统上的线程是“抢占式执行” “随机调度”的,这为线程之间的执行顺序带来了很多变数。
2、代码结构:代码中存在多个线程,同时修改同一个变量。
(1)一个线程修改一个变量,没事.
(2)多个线程读取同一个变量,没事
(3)多个线程修改不同变量,没事
3、直接原因:上述多线程修改操作,本身不是“原子的”。
count++这个操作,是包含多个CPU指令的,一个线程执行这些指令,执行到一半时可能被调度走,给其他线程一些“可乘之机”,应该做到的是:每个CPU指令,要么都是“原子的”,要么不执行,要么就执行完。
4、内存可见性问题。
5、指令重排序问题。
5、解决线程不安全问题
知道了原因,我们就可以开始着手解决线程不安全问题了。
针对原因1:系统内部已经实现抢占式执行,我们无法进行干预。
针对原因2:要根据业务情况,有时可以调整代码结构,但有时候,就是要通过多线程在代码中修改同一个变量,则无法调整。
针对原因3:看起来,count++包含的三个指令是在系统内部的,我们也无法进行干预。但实际上我们可以通过代码将这三个指令打包在一起,使它们变成“原子的”。
我们可以通过加锁的方式将这多个操作打包成一个整体(即原子的操作)。
6、通过synchronized 关键字进行加锁
锁的特性:
1、互斥、排他
synchronized会起到互斥的效果,两个线程,如果一个线程针对一个“锁对象”(加锁解锁都是依托这里的锁对象来展开的)加上锁之后,其他线程,也尝试对这个对象进行加锁,就会产生阻塞(锁竞争),一直阻塞到前一个线程释放锁为止。
进入synchronized修饰的代码块,相当于加锁。
退出synchronized修饰的代码块,相当于解锁。
2、可重入
Java中的锁是可重入锁,synchronized同步块对一个线程来说是可重入的,不会出现自己把自己锁死的问题。
而java中的锁是可重入锁,因此没有上述的问题:那么Java是怎么实现可重入锁呢?
Java中的锁会记录两个信息:1、当前锁是哪个线程所持有的。2、加锁次数的计数器。
如果当前对一个已经上锁的线程再加上同样锁对象的锁,那么就不会上锁,只会让当前计数器++。
所以在java中能够实现这样的操作:
public class ThreadDemo21 {private static int count;public static void main(String[] args) throws InterruptedException {Object locker = new Object();Thread t = new Thread(()->{synchronized (locker){//对于可重入锁来说,内部会有两个信息://1、当前这个锁是被那个线程持有的//2、加锁次数的计数器synchronized (locker) {//count++for (int i = 0; i < 1000000; i++) {count++;}}//count--//其他逻辑}//在这个大括号真正解锁});t.start();t.join();System.out.println(count);}
}
解决刚才的线程不安全案例
根据上面的学习我们知道:只需要给这两个线程加上同一个锁对象的锁就能够解决问题了。
代码如下:
public class ThreadDemo19{//存在线程安全问题的代码//多个线程同时修改相同的数据 -> 线程安全问题private static int count = 0;//如果多个线程修改不同的变量此时也不会有问题//单个线程修改变量也不会有问题private static int count2 = 0;public static void main(String[] args) throws InterruptedException {Object locker = new Object();//创建两个线程每个线程都针对count这个变量循环自增5w次//进入{} -> 上锁 出了{} -> 解锁Thread t1 = new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (locker) {count++;}//如果多个线程对变量进行读取是不会有线程安全问题的// System.out.println(count);}});Thread t2 = new Thread(()->{for (int i = 0; i < 50000; i++) {synchronized (locker) {count++;}//count2++;//System.out.println(count);}});t1.start();t2.start();//等待t1、t2结束后再进行打印t1.join();t2.join();System.out.println("count = "+ count);}
}
分析如下:当t1获取到锁时,由于使用的是同一个锁对象,此时t2就会阻塞,只有当t1解锁时,t2才能获取到锁继续执行。
前面我们讲过:加锁是把count++的三步操作变成原子的,但是需要注意的是,并非是加锁之后,执行三步操作的过程中,线程就不调度了,是即使加锁的线程被调度走了,上了同一锁对象的其他进程也无法插队执行。 说是原子其实是不严谨的,因为执行过程中t1任然有可能被调度出CPU,被其他不是上了同一锁对象的线程插队。准确来说,是通过锁竞争。让第其他未持有锁的线程的指令无法插入到当前持有锁的线程执行指令中,而不是禁止当前持有锁的线程被调度出CPU。
加锁之后,确实会影响到进程的执行效率,但是,即使如此,也是比一个线程串行执行要快的多的。如下代码,在 for 循环中,还有更多的指令,每次循环都需要条件判定(条件跳转指令),i++ 也是 三个指令。
for (int i = 0; i < 50000; i++) {synchronized (locker) {count++;}
}
此处的代码,加锁只是给 count++ 加锁的,t1 和 t2 的 count++ 部分是串行执行的,但是 for 循环部分,是并发执行的,这样做仍然是有意义的,仍然比所有代码都在串行执行要更快。
在实际开发中,往往一个线程中,要完成很多工作 1 2 3 4 5,很多工作中,只有某几个,是需要加锁的,剩下的其他都是可以并发执行的。比如 1 2 3 4 5,其中,1 2 3 5 都能够并发执行,4 需要加锁串行执行。
关于 Object locker = new Object(); 这里任意一个 Object 都行,是什么对象不重要,重要的是,两个线程之间,是否使用的是同一个锁对象,是同一个锁对象,就会产生竞争,不是同一个,就不会有竞争 。
7、对锁的补充
1、一个线程加锁,另一个线程不加锁,会存在线程安全问题。因为在这种情况下不存在锁竞争。
2、如果两个线程,针对不同的对象进行加锁,也会存在线程安全问题。
3、针对加锁操作一些混淆的理解:
有如下代码:
class Test{public int count = 0;public void add() {//针对this加锁synchronized (this) {count++;}}}
public class ThreadDemo20 {public static void main(String[] args) throws InterruptedException {Test test = new Test();Thread thread = new Thread(()->{for (int i = 0; i < 50000; i++) {test.add();}});Thread thread2 = new Thread(()->{for (int i = 0; i < 50000; i++) {test.add();}});thread.start();thread2.start();thread.join();thread2.join();System.out.println("count = "+ test.count);}
}
上面的代码由于synchronized中的对象,this指向的同一个对象(test),才能加锁成功(存在锁竞争),如果当前调用add()方法的是不同对象,此时的this不是同样的对象,不会存在锁竞争。
上面的加锁代码还可以写成下面这种形式:
synchronized public void add(){count++;}
除此之外我们还能通过类对象进行加锁,在一个Java进程中,一个类的对象都是只有一个的。因此,在一个线程中拿到类对象和在另一个线程中拿到的类对象,是同一个对象,因此,锁竞争任然存下,还是能够保障线程安全的。
对类对象的补充:
我们编写一段Java代码后,会保存为 .java 文件,然后经过编译后为 .class 的字节码文件,JVM 执行 .class 的时候,就要先把这个 .class 文件读取到内存中,然后才能执行(类加载)。JVM在读取.class 文件到内存中,需要一些特定的数据结构,来表示加载好的这些数据,==》 类对象,类名.class 就会得到这个类的对象,每个类都会有一个类对象,类对象里包含了这个类的各种信息(类的名字是啥,有那些属性,每个属性叫什么名字,是什么类型,有什么方法,每个方法叫什么名字,有什么参数,参数是什么类型,有什么注解,继承自哪个类,实现了那些接口...)
类对象,是反射机制的依据。
下面的代码是通过类对象进行加锁:
public void func(){//给类对象加锁synchronized (Test.class){count++;}}
synchronized static void func(){count++
}
所以,并不是说:一个线程加上synchronized,就一定线程安全了,关键还是看代码是怎么写的。当前的锁对象是不是同一个,另一个需要加锁的线程有没有加上锁。