目录
为什么会有线程安全问题
多线程的抢占式执行,带来的随机性
如果没有多线程,此时程序代码执行顺序就是固定的只有那么一条路,代码顺序是固定的,那么程序结果就是固定的,那么线程肯定就是安全的
但是如果有了多线程,此时抢占执行下,代码执行顺序,就从一种情况变成了无数种情况 所以我们就需要保证这无数种线程调度顺序的情况下,代码的执行顺序结果都是正确的,才会线程安全
此时 我们可以写一个这样的代码 使用两个线程对count进行++操作 ,根据我们的推想运行结果应该为10 0000 但是实际运行结果却是不唯一的 此时的代码就是线程不安全的
class Counter{
public int count;
public void add1(){
count++;
}
}
public class Threaddemo13 {
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() ->{
for(int i = 0;i < 50000;i++){
counter.add1();
}
});
Thread t2 = new Thread(() ->{
for(int i = 0;i < 50000;i++){
counter.add1();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(counter.count);
}
}
而为什么程序会出现这个情况呢?
++操作本质上可以分为三步
1.load操作:先把内存中的值操作,读取到CPU的寄存器中
2.add操作:把CPU寄存器里的数值进行+1操作(寄存器是CPU中重要的组成部分,也能存数据,空间更小,访问速度更快,CPU中进行的运算都是针对寄存器中的数据进行的)
3.save操作:把得到的结果写回到内存中去
而如果两个线程并发执行count++,此时就相当于两组load add save进行执行,此时不同的线程调度顺序,就可能会产生一些结果上的差异,如图列举所示,像这样的调度情况有无数种。
第一种情况是线程安全的 执行过程 首先内存中有一个变量count == 0 而t1进行了load操作 那么t1线程中CPU寄存器中count的值记为0 ,再接着t1执行add操作 t1CPU寄存器中的count值变为1,t1再进行save操作 将count == 1放回到内存中去,此时t2再进行读操作的时候 count的值已经变成了1 再将它进行add和save操作 而最后count == 2;这里count自增两次,结果为2
第二中情况是不安全的 首先内存中有一个变量count == 0 ,t1线程会进行load操作 t1线程中CPU寄存器中count的值记为0,再接着t2也会进行load操作,t2线程中的CPU寄存器中count的值也会记为0,接下来t1进行add和save,t1里count变为1并且将count == 1 存到内存中去,再接下来t2进行add和save,t2里count变为1并且将count == 1 存到内存中去,而这里count自增两次 结果为1
线程安全问题的原因(这里只讲述5个典型的原因)
1.抢占式执行,随机调度【根本原因】
2.代码结构:多个线程同时修改同一个变量 (比如说String 是不可变对象,不可变对象,天然是线程安全的)
3.原子性:指单个指令无法再进一步拆分了。 如果上面的++操作也是原子的,那么此时线程就是安全的,但是count++ 这里可以拆分load,add,save三个操作 所以++操作不是原子的 可以用加锁来讲非原子的改为原子的从而解决线程安全问题
4.内存可见性问题: 一边读一边写 也可能出现问题 可能此处读的结果不符合预期
5.指令重排序:编译器再保持逻辑不变的情况下 自己调整了你代码的执行顺序 从而加快看程序的执行效率,就是编译器再优化你代码的时候优化出bug了(单线程)
synchronized关键字
关于加锁(解决线程安全问题)
synchronized修饰add方法 对counter这个对象加锁 其他线程可以使用这个对象里没加锁的方法
首先我们要明确加锁对象
如果两个线程对同一个对象进行加锁,就会出现锁竞争/锁冲突。比如说我和小明去银行自助取款机上取钱,一个自动取款机只能服务一个人,当小明进去,就相当于他对自动取款机加锁,而我作为另一个要获取锁的线程,那么我只能在外面阻塞等待,等到解锁也就是小明从自助取款机里出来,我才能尝试加锁,而当小明进去后,就算小明没有使用自助取款机,但是他没有释放锁,只要他不释放锁,我就得仍然得在外面阻塞等待 就好比例如第二种情况虽然t1这会没在CPU上执行,但是没有释放锁,t2仍然得阻塞等待
如果两个线程针对不同对象加锁,此时不会发生锁竞争/锁冲突,这两线程都能各自获取到锁,此时不会发生阻塞等待了 就好比我和小明一人使用一个自助取款机 我不需要等待他使用完再使用。
当一个线程加锁,一个线程不加锁,那么就没有锁竞争,相当于没有加锁
加锁本质是把并发,变成了串行能够获取到锁(先到先得)另一个线程阻塞等待,等待到上一个线程解锁,它才能获取成功
synchronized使用方法:明确对哪个方法进行加锁
1.修饰方法:进入方法就加锁 离开方法就解锁
synchronized public void add1(){
//用synchronized修饰刚才的add1方法,对counter这个对象加锁
//此时运行结果就为10 0000
count++;
}
1)修饰普通方法 :修饰普通方法,锁对象就是this
2)修饰静态方法:修饰静态方法,锁对象就是类对象(Counter.clss)
2.修饰代码块://进入代码块就加锁 出了代码块就解锁
public void add2(){
synchronized (this){
//修饰代码块
count++;
}
}
synchronized是可重入锁
一个线程针对同一个对象,连续加锁两次,没有问题就是可重入锁
java标准库中的线程安全类
如果多个线程操作同一个集合类,就要考虑到线程安全问题
像Arraylist ,LinkedList,HashMap,TreeMap等等这些类中没有加锁 ,像StringBuffer,ConcurrentHashMap这些类中已经内置了synchronized,更安全一点 ,但是加锁这个操作是会有额外的时间开销,那么如果没有线程安全问题就不需要加锁,但是像StringBuffer这样强行加锁反而是浪费时间。String类里是不可变对象,虽然没有加锁,那它也是安全的。