线程不安全:在多线程的调度情况下,导致出现了一些随机性,随机性是代码中出现了一些BUG,导致我们的线程是不安全的
造成线程不安全的原因:
1)操作系统抢占式执行,线程调度随机,这是万恶之源,我们无能为力
2)多个线程同时修改同一个变量,适当调整代码结构,避免这种情况
3)针对的变量操作不是原子的,count++本质上是三个指令:load,add,save
4)内存可见性:一个线程读,一个线程写,直接读寄存器,不读内存,这也是一种优化
5)指令重排序:编译器优化,CPU自身的执行
在这里面,涉及到一个重要的知识点,JMM(java memory model内存模型)
1)我们正常情况下,是想要把内存中的数据读到CPU里面进行操作,但是实际上在CPU和内存中还会存在这一些缓存,为了提高CPU和内存之间的读写效率
2)当我们在代码中读取变量的时候,不一定是在真的读内存,可能这个数据已经在CPU或者cathe中缓存着了
3)这个时候就可能会绕过内存,直接从CPU寄存器里面或者cathe中取数据;咱们的JMM针对计算机的硬件结构,又进行了一层抽象,主要是因为Java是要跨平台,要能够支持不同的计算,有的计算机可能没有catche2内存,可能也会有catche3内存,当这个变量进行修改的时候,此时读的这个线程没有从内存里面读,而是从catche里面读,或者CPU中的寄存器里面读,就会出现内存可见性的问题;4)KMM就会把CPU中的寄存器,L1,L2,L3catche,统称为工作内存;把真正的内存称为主内存,工作内存一般不是真的内存,每一个线程都会有自己的工作内存,每个线程都有独立的上下文,独立的上下文就是各自的一组寄存器/catche中的内容
5)CPU和内存进行交互时,经常会把主内存中的内容,拷贝到工作内存,然后再进行工作,写会到主内存中,这就可能会出现数据不一致的情况,这种情况是在编译器进行优化的时候特别严重,关键字volitaile和synchronized就可以强制保证接下来的操作是在操作内存,在生成的java字节码中强制插入一些内存屏障的指令,这些指令的效果,就是强制刷新内存,同步更新主内存和工作内存中的内容,在牺牲效率的时候,保证了准确性
注意:synchronized与voilatile的区别
他们都可以保证内存可见性,但是synchronized可以保证原子性,volitaile不可以保证原子性
1)计算机想要执行一些计算,就需要把内存中的数据写入到CPU的寄存器里面,然后再在寄存器中进行计算,再写回到内存里面,直接读寄存器,CPU访问寄存器的速度是要比访问内存的速度要快的多,当我们的CPU连续多次访问内存,发现结果都一样,就不会从内存里面读了,就直接会从CPU的寄存器里面读就可以了;
2)在Java中,我们要先把数据从主内存加载到工作内存里面,工作内存计算完毕之后在写回到主内存里面
3)咱们的类加载双亲委派模型就是描述了先去哪一个目录找.class,后去哪一个目录中找
4)CPU从内存中取数据,取得太慢了,尤其是频繁进行取的时候,我们就可以把这样的数据放到寄存器里面,然后直接从寄存器里面读,但是我们的寄存器空间是很紧张的
5)于是我们的CPU又搞了一个内存空间,这个空间比寄存器大,比内存小,速度比寄存器慢,比内存快,我们就称之为缓存
1.存储空间:CPU<L1<L2<L3<内存
2.速度:CPU>L1>L2>L3>内存
3.成本:CPU>L1>L2>L3>内存
最最常用的数据放到CPU寄存器里面,其次常用的放到L1里面,再次常用的放到L2里面
我们的可重入锁的意义就是降低了程序员的负担(提高了开发效率)
缓存+CPU寄存器=CPU
6)咱们的synchronized的使用是要付出代价的,代价就是一旦使用synchronized很容易会导致线程阻塞,一旦线程阻塞(放弃CPU),下一次我们再次回到CPU就会变得很困难,这个时间是不可控的,如果调度不回来,自然对应的任务执行时间也就被拖慢了,使用synchronized就会和高性能无缘,volatile不会使线程阻塞
但是我们的程序也会有更高的开销(维护锁属于哪一个线程,并且进行引用计数,降低了运行效率)
如果我们的使用的是不可重入锁,此时我们的开发效率就低了,一不小心,咱们的代码就很容易写出BUG,如果BUG严重,就会造成严重后果
3.标准库中的集合类大部分是线程不安全的
ArrayList/LinkedList/HashMap/HashSet/Treeset/StringBuilder都是线程不安全的;
线程安全:Vector(JDK早期内置的一个集合类,这里面的设计并不是特别合理,他也是一个顺序表,是一个动态数组,他这里面使用了synchronized来修饰了很多方法,大多数情况下加上synchronized就会使单线程环境下的操作的效率造成负面影响;会禁止编译器的优化,对标ArrayList);
HashTable线程安全,不建议使用
Stack线程安全,ConcurrentHashmap,StringBuffer
String是线程安全的,没有进行加锁,String是不可变对象,不可能存在两个线程同时修改一个相同的String对象,没有提供对public 的修改属性的操作(修改char[]数组)
而final表示String不能被继承
我们使用线程安全的集合类,那么有了这个操作,我们就可以保证在多线程环境下,我们修改同一个对象,就不会有太大的问题
Synchronized(也叫做监视器锁)有什么用处呢?
1)保证操作是原子的,互斥
2)synchronized不光可以起到互斥的效果,还能够刷新内存,解决内存可见性的问题
例如在一个代码中循环的进行++操作,每次自增,都不是原子的,编译器会优化这里面的效率,把从内存中读取数据到CPU中,和把CPU中的数据放回到内存中这些过程会省略;
2.1)加上synchronized之后,就会优化上面的操作,保证把数据从内存里面读,也会真正的把数据写回到内存
2.2)也是让程序跑的慢一点,但是能够算得准,一旦代码中使用了synchronized,此时咱们的程序就可能与高性能无缘了,在想跑快就很困难了;
2.3)本来在单线程中,我们是可以进行优化的,减少访问内存的操作,但是加上synchronized之后,就必须强制访问内存的数据到寄存器里面;
3)避免可重入:synchronized允许一个线程针对同一把锁,咔咔加锁两次,如果出现了死锁,那么就是不可重入的,如果是不会发生死锁,那么就是可重入的锁synchronized public void increase(){ synchronized(this){ count++; } }
上面这种情况就是说外层先针对当前对象加了一次锁,在里层又对这个对象再加了一次锁,这就是一次锁两次
1)外层锁:进入方法之后,就开始进行加锁,这次我们能够加锁成功,因为当前锁是没有其它线程进行占用的
2)里层锁:当我们对外层方法进行加锁之后,进入代码块之后,开始尝试进行加锁,这一次加锁是不能够加锁成功的,因为按照之前的观点进行分析,锁在外层是被占用这呢,只有当前持有锁的方法执行完之后,我们才可以获取到锁;外层锁只有执行完整个方法,才可以释放锁,但是想要执行完整个方法,我们就需要让里层锁加锁成功之后继续走下去
3)所以我们的锁就永远无法释放,不会有任何的线程可以获取到锁了,就发生了死锁
死锁:是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进
1)进入increase之后加了一次锁,进入代码块之后又加了一锁,在这里面synchronized在这里进行了特殊处理;
2)如果是其他语言的锁操作,就有可能造成死锁;
第一次加锁,加锁成功
第二次在尝试对这个对象头加锁的时候,此时对象头的锁标记已经是true,按照之前的理解,线程就要进行阻塞等待,等待这个所标记被改成false,才重新竞争这个锁,但是此时是在方法内部,肯定是无法释放第一次所加的锁的,就出现死锁了;synchronized public void increase() { count++; } synchronized public void increase2() { increase(); }
synchronized public static void run() { synchronized (Student.class) { System.out.println("我叫做run方法"); } }
3)在可重入锁内部,会记录当前的锁是被哪一个线程占用的,同时也会记录一个加锁次数,3.1)当我们的线程A对这个锁进行第一次加锁的时候,显然是能加锁成功的,锁内部就会进行记录,当前占用着的线程是A,同时加锁次数是1,后续我们再次针对这个线程A进行加锁的时候,此时就不会真的加锁,而只是单纯的把引用计数给自增,加锁次数是2,后续我们进行解锁的时候,再把引用计数减1,当我们进行把引用计数减到0的时候,就真的进行解锁,所以说后续的加锁操作对这个线程持有这把锁是没有本质影响的;
3.2)咱们的可重入锁的意义就是为了降低程序员的负担,降低使用成本,提高了开发效率,但是也带来了代价,程序中需要有额外的开销,因为我们要维护锁属于哪一个线程,并且进行加减计数,这个变量还占用空间,降低了运行效率,开发效率最重要
3.3)如果说我们使用的是不可重入锁,此时我们的开发效率就降低了,如果我们一不小心,代码中就出现死锁了,线上程序出BUG了,就需要修BUG了,如果BUG严重了,年终奖可能会泡汤
4)解决指令重排序,避免乱序执行
出现死锁的其他情况:
1)一个线程针对一把锁咔咔枷锁两次
2)两个线程,两把锁
比如说我和我朋友去吃饺子
我:吃饺子习惯蘸酱油
朋友:吃饺子习惯蘸醋
2.1)当我拿起了酱油之后,我的朋友拿起了醋,我说:请你把醋给我,朋友说:请你把酱油给我
2.2)我说:你先把醋给我,我用完了,我就给酱油,朋友说:你先把酱油给我,我用完了就给你醋,我们在这里面可知,醋和酱油就是两把锁
3)N个线程,M把锁
synchronized(A){ synchronized(B){ synchronized(c){ } } }
实际上咱们如果说不使用嵌套锁,那么也不会容易出现死锁,如果咱们的适应场景,不得不进行嵌套,那么我们一定要注意约定好加锁的顺序,所有的线程都用a-b-c这样的顺序进行加锁,千万别的线程用c-a-b的方式进行加锁,或者是a-c-b,否则就会很容易发生环路等待
哲学家共餐问题:
1)我们的每一个哲学家什么时候思考人生,什么时候吃面条,是不确定的,每一个哲学家就对应着我们的线程,这样的思考人生和吃鸡的随机性也就体现着我们线程调度的随机性;
2)我们的哲学家进行吃面条的时候,都需要拿起来他身边的两根筷子,假设先拿起左手的筷子,再拿起来右手边的筷子
3)咱们的哲学家是非常固执的,如果想吃面条的时候,尝试拿筷子的时候,发现筷子一直被别人占用着,就会一直等,如果在这个模型中如果五个哲学家,同时伸出左手,拿起来左手边的筷子,就会发生死锁问题
4)每一个人都成功的拿起来了左手边的筷子,但是永远无法成功拿起来右手边的筷子,而我们的哲学家又是非常固执地,不肯放下自己手中的筷子,那么所以说最后谁也不会成功吃到面条
咱们只需要约定好,针对多把锁进行加锁的时候,我们有固定的顺序就好了,当我们所有的线程都遵守同样的规则顺序,就不会出现环路等待;
解决哲学家共餐问题:
1)我们的解决方法就是给当前所有的筷子都编上号,我们约定让哲学家拿筷子(同时拿筷子),不是先拿左手,再拿右手
2)而是哲学家先拿编号小的,再拿编号大的(咱们的哲学家是非常固执地,只会拿他身旁的两个筷子中的较小的那个,即使较小的那个筷子被别人拿了,也会等待,虽然旁边有编号较大的筷子,但是还是优先拿起编号小的筷子)
总结:死锁的四个必要条件
1)互斥使用:一个线程被另一个线程占用了之后,其他线程无法进行占用,锁的本质,保证原子性
2)不可抢占,不可剥夺性:一把锁被另一个线程占用了之后,其他的线程是无法把这把锁给抢走的
3)请求和保持:当一个线程占据了多把锁之后,除非显示的进行释放锁,否则这些锁都是该线程所持有的
4)环路等待,在我们的实际开发中,如果说想要避免死锁,那么我们关键要点还是从第四个条件来进行切入,
当synchronized修饰方法的时候有以下需要进行注意:
1)synchronized关键字不可以被继承:虽然我们可以使用synchronized来进行修饰方法,但是synchronized并不属于方法中的一部分,因此synchronized关键字不可以被继承,如果说你在父类中的某一个方法中使用了synchronized关键字,在子类中重写了这个方法,在子类中的某一个方法并不是同步的,必须显示的在子类中加上synchronized关键字才可以
2)定义接口方法中不能使用synchronized关键字
3)在构造方法中不能使用synchronized关键字,但是可以使用synchronized同步代码快来进行同步
总结:synchronized修饰普通方法和同步代码快指定this表示给当前对象进行加锁,就是当不同线程尝试访问一个对象中的synchronized(this)同步代码快的时候,其他访问该对象的线程将会被阻塞
class RunnableTask implements Runnable{ public void GetCount(){ System.out.println("生命在于运动"); } public void run() { synchronized (this){ try { TimeUnit.SECONDS.sleep(10); System.out.println("我是中国人"); } catch (InterruptedException e) { e.printStackTrace(); } } } } class Solution { public static void main(String[] args) { RunnableTask task=new RunnableTask(); Thread t1=new Thread(task); Thread t2=new Thread(task); t1.start(); t2.start(); //上面这种情况就会发生阻塞 //但是下面这种情况就不会发生阻塞,不会产生锁的竞争 RunnableTask task1=new RunnableTask(); RunnableTask task2=new RunnableTask(); Thread t3=new Thread(task1); Thread t4=new Thread(task2); t3.start(); t4.start(); } }
解释:
1)当我们的两个并发线程t1和t2在进行访问同一个对象的synchronized(this)修饰的同步代码快的时候,同一时刻只能有一个线程被执行,一个线程被阻塞,必须等待一个线程执行玩这个同步代码快之后另一个线程才可以执行这个代码块
2)t1和t2是互斥的,因为在我们执行synchronized代码块的时候会进行锁定当前的对象,只有执行完该同步代码快的才能释放该对象的锁,下一个线程才能执行并且锁定该对象
3)但是被注释掉的那一片代码,t3和t4在同时进行执行,因为他们是访问两个对象中的被synchronized修饰的方法或者是同步代码快,这是因为synchronized只锁定对象,每一个对象只有一把锁与之相关联
4)当一个线程访问一个对象的synchronized的同步代码快的时候,另一个线程仍然可以访问该对象的非同步代码快