- 关于原子性的问题:
- 如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,都能保证其满足原子性了。
- Synchronized
- 是锁的一种实现。synchronized 关键字可以用来修饰方法和代码块。Java编译器自动在代码块前后加锁解锁
- 修饰方法时的锁定对象:
- 当修饰静态方法时,锁定的是当前类的 Class 对象
- 当修饰非静态方法时,锁定的是当前实例对象 this。
- 锁和受保护资源的关系
- 受保护资源和锁之间的关联关系是 N:1 的关系,可以用一把锁来保护多个资源,但是不能用多把锁来保护一个资源。并且锁需要覆盖所有受保护资源
- 使用 Object(当前对象).class 作为共享的锁,就无需在创建对象时传入了。
- Object(当前对象).class 是所有 Object(当前对象)对象共享的,而且这个对象是 Java 虚拟机在加载 Object(当前对象)类的时候创建的,所以它是唯一的。
- 细粒度锁:用不同的锁对受保护资源进行精细化管理,能够提升性能
- 如果资源之间存在关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源
- 原子性问题的本质:并不是不可分割,不可分割只存在于外在表现,本质是多个资源间有一致性的要求,操作的中间状态对外不可见;解决原子性问题,是要保证中间状态对外不可见
- 关于死锁
- 使用细粒度锁可能会导致死锁
- 死锁:一组互相竞争资源的线程因互相等待,导致永久阻塞的现象
- 如何产生死锁
- 互斥,共享资源X和Y只能被一个线程占用
- 占有且等待,线程T1已经取得了共享资源X,在等待共享资源Y的时候,不释放共享资源X
- 不可抢占,其他线程不能强行抢占线程T1占有的资源
- 循环等待,线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源
- 避免死锁(只要我们能够破坏上条的一个条件就可以成功避免)
- 互斥无法避免,因为锁用的就是互斥锁
- 对于占有且等待,可以一次性申请全部资源
- 对于不可抢占,占有一部分资源的线程想继续申请其他资源时,如果申请不到,可以主动释放他占有的资源,以退为进(如果申请不到是因为其他线程占用并且等待当前线程所占有的资源时,释放掉资源把他要的给他,等他执行完了咱们在执行)
- 对于循环等待,可以靠按序申请资源来预防。所谓按需申请是指资源是存在线性顺序的,申请的时候可以先申请资源序号小的,在申请大的,这样线性化后就不会循环等待了
案例:
-
- 破坏占用且等待条件
- “同时申请”这个操作是一个临界区,我们用 Allocator这个类来管理这个临界区。它有两个重要功能:同时申请资源 apply() 和同时释放资源 free()。
- 账户 Account 类里面持有一个 Allocator 的单例(必须是单例,只能由一个人来分配资源)。当账户 Account 在执行转账操作的时候,首先向 Allocator 同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,我们需通知 Allocator 同时释放转出账户和转入账户这两个资源。
- 破坏不可抢占条件
- 主动释放本线程占有的资源,这一点synchronized无法做到。
- 原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
- 不过在 SDK 层面还是解决了的,java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的。(提供tryLock(long, TimeUnit) 方法,在一段时间尝试获取锁。)
- 破坏循环等待条件
- 破坏这个条件,需要对资源进行排序,然后按序申请资源。我们假设每个账户都有不同的属性 id,这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。比如对转出账户(this)和转入账户(target)排序,然后按照序号从小到大的顺序锁定账户。这样就不存在“循环”等待了。
- 在破坏占用且等待条件时,如果使用while死循环,在并发量不大的情况下循环几十上百次也就好了,如果while中执行方法时间比较长,或者并发量大时,可能要循环上万次才能获取到锁,非常消耗CPU
- 相较于使用while死循环,更好的方案是:
- 如果线程要求的条件不满足,则线程阻塞自己,进入等待状态;当线程的要求满足后,通知等待的线程重新执行
- 使用线程阻塞的方式可以有效避免循环等待消耗过多CPU 的情况
- 完整的等待-通知机制
- 线程首先获取互斥锁
- 当线程要求的条件不满足时,释放互斥锁,进入等待状态
- 当要求满足时,通知等待的线程,重新获取互斥锁
- 用synchronized可以实现等待-通知机制
- 下图中,左侧有一个等待队列,同一时刻只允许一个线程进入synchronized保护的临界区,当有一个线程进入临界区后,其他线程就只能进入左侧等待队列中(这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列)
-
- 在并发程序中,当一个线程进入临界区,由于某些条件不满足,需要进入等待队列,此时wait()满足需求
- Wait()执行后,当前线程会被阻塞,并且进入右侧的等待队列(右侧的等待队列就是互斥锁的等待队列),进入等待队列的同时,会释放所有的互斥锁,线程释放锁之后,其他线程就有机会获得锁,并进入临界区
- 之后当满足条件时使用notify()/notifyAll()来通知互斥锁的等待队列中的线程,告诉他曾经条件满足过;只有通知时间的此时此刻条件是满足的,而被通知线程的执行时间点和通知的时间点基本上不会重合,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。
- 被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用 wait() 时已经释放了)。
- Notify()和notifyAll()的区别
- 尽量使用notifyAll(),使用notify() 很有风险,它的风险在于可能导致某些线程永远不会被通知到。
- notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程。
- notify() 何时可以使用
- 所有等待线程拥有相同的等待条件;
- 所有等待线程被唤醒后,执行相同的操作;
- 只需要唤醒一个线程。
- 案例
- 假设我们有资源 A、B、C、D,线程 1 申请到了 AB,线程 2 申请到了 CD,此时线程 3 申请 AB,会进入等待队列(AB 分配给线程 1,线程 3 要求的条件不满足),线程 4 申请 CD 也会进入等待队列。我们再假设之后线程 1 归还了资源 AB,如果使用 notify() 来通知等待队列中的线程,有可能被通知的是线程 4,但线程 4 申请的是 CD,所以此时线程 4 还是会继续等待,而真正该唤醒的线程 3 就再也没有机会被唤醒了。
- wait() 方法和 sleep() 方法都能让当前线程挂起一段时间,那它们的区别是什么?
- wait释放资源,sleep不释放资源
- wait需要被唤醒,sleep不需要
- wait需要获取到监视器,否则抛异常,sleep不需要
- wait是object顶级父类的方法,sleep则是Thread的方法
两者相同点:都会让渡CPU执行时间,等待再次调度!