什么是线程安全?
线程安全是我们多线程的核心问题 ~ ,也是多线程中很难驾驭的一个难点,
我们来举个"栗子":
public class ThreadDemo11 {
static class Counter{
public int count = 0;
public void increase(){
count++;
}
}
static Counter counter = new Counter();
public static void main(String[] args) throws InterruptedException {
//创建两个线程,分别自增五万次
Thread t1= new Thread(){
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
}
};
Thread t2= new Thread(){
@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.count);
}
}
我们运行上述的代码后就会发现,结果并不是10万 ! ~, 而且每次的运行结果都是不相同的 ~
我们可以把它当成一个BUG来看待,那么这种情况是如何产生的呢?
我们可以猜到大概率是并发编程而引起的 ~ ,
这种情况我们就视为"线程不安全" ~
我们来分析一下上述的执行过程~
在上述代码中的自增函数increase() ,它在上述线程中执行时,一直是循环的自增,来实现count++;
count++ 在计算机中的实际步骤可以分为三步:
1.从内存中读取count 到CPU中~(LOAD)
2.CPU实现++操作~(ADD)
3.将CPU中的数据又写回内存中(SAVE)
我们可以看到count++的操作步骤就有三步,再之前的文章中我说过线程的并发执行是一种"抢占式执行",在两个线程并发执行的时候他们会抢占资源来执行,执行的过程可能就是以下这几种情况
在这种情况下,我们可以知道两个线程的执行并不会对结果产生不好的影响,可以得到我们对应的结果,但是线程的执行不只是这种情况,还有可能是
如果是上图这种情况我们就会发现,假如,这时候count = 1,
当线程1执行LOAD后,并没有立刻被执行线程1的ADD等操作,而是线程2开始执行对应操作,线程2操作后将自增的结果写回内存中,内存中count变为了2,
之后线程1的ADD开始执行,这时候线程1执行的ADD还是线程1读到的CPU中的数据count = 1,然后进行自增以及写回内存中,内存中的count最后还是2,
我们就可以发现,线程1,2各自执行了一次后,count只自增了1个,而在系统内核中的线程执行操作不是我们能说了算的,具体怎么执行还要看操作系统自己的断定,这样的随机性就导致了我们最后得到的结果与我们的预期并不相同.
(只用串行操作时候,才不会出现这种情况的~)
上述代码的情况就是我们所说的"线程不安全"~~
线程不安全的原因
根据上面的例子,我们可以得到线程不安全产生的原因有几种:
1~ 线程之间是 “抢占式执行” 的 , 这样的执行方式导致了我们并不能知道线程执行顺序的先后,如此大的随机性也就导致了我们"线程不安全"问题的发生~
2~ 多个线程修改同一个变量~
当两个线程修改同一个变量时,就如上文中的代码一样,会出现相同情况,出现问题
3~ 原子性~
像刚刚的count++操作,本质上就是三个步骤,这就是一个"非原子性"的操作,这种操作可能就会导致问题的产生 , 像 = 这种操作本质上就是一个赋值操作,就是一个步骤,"原子"的操作就不会产生"线程不安全"的困扰~
4 ~内存可见性
这种是与原子性相似,主要是与编译器的优化有关~
例子:
线程1,读取变量
线程2,对变量循环自增~
很多次自增就会涉及很多次LOAD和SAVE,CPU执行ADD速度比另外两个速度快一万倍,为了提高程序的整体效率,线程2,就会把中间的LOAD和SAVE操作省略掉,连续执行很多次ADD,这个省略操作是编译器(javac)和JVM(java)综合配合达成的效果 ,这种线程一的LOAD读不到SAVE,读到的就是0 !~
因此,一个线程修改,一个读取,由于编译器的优化,
读到的可能就是未修改过的结果~.
5 ~ 指令重排序
编译器在运行时,会自动的调整指令执行的顺序,来达到更高的效率,如果是单线程的话,这种操作就不会对结果产生影响,但是多线程下,这个就无法保证了编译器判断的顺序在逻辑上是否准确~
如何解决"线程不安全"的问题
代码中如果"线程不安全"那么带来的问题将会影响非常之大,那我们如何来解决这些问题呢?
1 . synchronized
我们可以通过"加锁"的方式让代码保持"原子性",来达到"线程安全"的目的~
我们通常使用synchronized 关键字来进行"加锁"
我们来对上面的例子进行修改
static class Counter {
public int count = 0;
//加锁
synchronized public void increase(){
count++;
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(){
@Override
public void run() {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
}
};
Thread t2 = new Thread(){
@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.count);
}
//运行结果
100000
通过在increase()前用synchronized来修饰它达到"加锁"的效果,让这个线程1先执行这个方法,另一个线程2等待,直到线程1执行结束,线程2才会开始执行,这样就避免了线程之间的"抢占"~
我们也会发现,这种方式会使我们的代码达到我们的需求,但同时效率也会降低~~
上述代码的increase方法可以改为:
public void increase(){
synchronized(this)
{
count++;
}
}
.这种也可以达到对应的效果
在上文中我们用的"锁",存在于java的对象头中,
对象头中会有一个对应的"锁标记",
我们的"加锁",就是把这个锁对象中的锁标记变为"true";
我们的"解锁",就是把这个锁标记变为"false";
注意:java中,所有的对象都可以是"锁对象"
synchronized还能够刷新我们的内存,来解决内存可见性问题
synchronized会禁止自增过程中的优化,保证每次的读写操作都真的能从内存中读,并且写回到内存中~(使得效率降低,准确性增强)
synchronized 可重入~
允许一个线程对一把锁二次进行"加锁",
例如~
synchronized public void increase(){
synchronized(this)
{
count++;
}
}
这样的操作在java中也是允许的~
synchronized 记录了当前的"锁"被哪个线程所持有~
在这里我们就可以总结下synchronized具有的三个特性~
1.互斥(让代码具有"原子性",串行执行)
2.刷新内存(解决内存可见性问题)
3.可重入(防止不小心代码写错,对线程重复加锁而造成"死锁")
需要注意的是:
synchronized对普通方法进行修饰,就是针对this进行"加锁",两个线程的并发执行会不会出现"锁竞争",就要看两个线程需要加锁的是不是同一个对象~
而synchronized对静态方法进行修饰,就是对类对象(.class可以获取当前类的对象信息)进行加锁,因为类对象是单例的,所以两个线程并发的调用这个方法,就一定会触发"锁竞争"~
2 . volatile
解决"线程安全"问题我们也可以使用volatile关键字
可以解决内存可见性问题,但不能保证原子性~
static class Counter{
public int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread()
{
@Override
public void run() {
while(counter.flag == 0)
{
//执行某些任务
}
System.out.println("循环结束");
}
};
t1.start();
Thread t2 = new Thread()
{
@Override
public void run() {
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个整数");
counter.flag = scanner.nextInt();
}
};
t2.start();
}
上述的代码运行后输入非0的数也并不会使程序停止~,主要原因是上述代码中循环的读取flag(频繁),编译器就对此进行了优化,后续直接从CPU中读取,而不是内存中,而线程2对内存中的flag进行了修改,但是线程1读不到flag的新数据,就带来了这样的问题 ~
我们就可以使用volatile来解决~
在上面的Counter类中,将里面的成员变量改为被volatile修饰的,
volatile public int flag = 0;
就可以解决我们的问题~
注意 : 一般来说,对于一个变量来说,一个线程读,一个线程写,则会用到volatile~
总结
在上述中我们谈到了"线程不安全"所带来的后果,以及什么原因造成了这样的问题,也给出了具体的解决办法(synchronized \ volatile),关于线程安全,我们还需要更加的去重视和探究 ~~~~