问题:
两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?
问题分析:
以上的结果可能是正数、负数、零。因为 Java 中对静态变量的自增、自减并不是原子操作,要彻底理解,必须从字节码来进行分析。
(1)一个程序运行多个线程本身是没有问题的
(2)问题出在多个线程访问共享资源
①多个线程读共享资源其实也没有问题
②在多个线程对共享资源读写操作时发送指令交错,就会出现问题
(3)一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
(1)阻塞式的解决方案:synchronized、Lock
(2)非阻塞式的解决方案:原子变量
synchronized,即俗称的【对象锁】,它采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其他线程再想获取这个对象锁时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
注意:
虽然 Java 中互斥和同步都可以采用 synchronized 关键字来完成,但有区别:
(1)互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码。
(2)同步是由于线程执行的先后顺序不同,需要一个线程等待其他线程运行到某个点。
思考:
synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。
为了加深理解,请思考下面的问题(评论区交流)
(1)如果把 synchronized(obj) 放在 for 循环的外面,如何理解?——原子性
(2)如果 t1 synchronized(obj1),而 t2 synchronized(obj2) 会怎样运作?——锁对象
(2)如果 t1 synchronized(obj1),而 t2 没有加会怎么样?——锁对象
(1)如果它们没有共享,则线程安全
(2)如果它们被共享了,根据它们的状态是否能否改变,又分两种期刊
①如果只有读操作,则线程安全
②如果有读写操作,则这段代码是临界区,需要考虑线程安全
(1)局部变量是线程安全的
(2)但局部变量引用的对象则未必
①如果该对象没有逃离方法的作用范围,它是线程安全的
②如果该对象逃离方法的作用范围,需要考虑线程安全
成员对象分析:
分析:
(1)无论哪个线程中的 method2、method3 引用的都是同一个对象中的 list 成员变量
局部变量分析:
分析:
(1)list 是局部变量,每个线程调用时会创建其不同实例,没有共享
(2)而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象
(3)method3 的参数分析与 method2 相同
方法访问修饰符带来的思考,如果把 method2 和 method3 的方法修改为 public 会不会带来线程安全问题?
(1)情况1:有其他线程调用 method2 和 method3
(2)情况2:在 情况1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法。
从这个例子可以看出 private 或 final 提供【安全】的意义所在,请体会开闭原则中的【闭】。
String
包装类(Integer 等)
StringBuffer
Random
Vector
Hashtable
java.util.concurrent 包下的类
这里说它们是线程安全的是指,多个线程调用它们同一个实例的方法时,是线程安全的。也可以理解为:
(1)它们的每个方法是原子的
(2)但注意它们多个方法的组合不是原子的
String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的。
Monitor 被翻译为监视器或管程。
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针。
(1)刚开始 Monitor 中 Owner 为 null。
(2)当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor 中只能有一个 Owner。
(3)在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList BLOCKER。
(4)Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的。
(5)图中 WaitSet 中的 Thread-0, Thread-1 是之前获得过锁的,当条件不满足加入 WAITING 状态的线程,后面涉及到 wait-notify 时会分析
注意:
(1)synchronized 必须是进入同一个对象的 Monitor 才有上述的效果。
(2)不加 synchronized 的对象不会关联监视器,不遵从以上规则。
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级来优化。
轻量级锁对使用者是透明的,即语法仍然是 synchronized。
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
(1)自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
(2)在 Java6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
(3)Java7 之后不能控制是否开启自旋功能。
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。
(1)偏向状态
一个对象创建时:
①如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0*05 即最后 3 位为 101,这时它的 thread、epoch、age 都为0。
②偏向锁是默认延迟的,不会再程序启动时立即生效,如果想避免延迟,可以加 VM 参数【-XX:BiasedLockingStartupDelay=0】来禁用延迟。
③如果没有开启偏向锁,那么对象创建后,markword 值为 0*01 即最后 3 位为 001,这时它的 hashcode、age 都为 0,第一次用到 hashcode 时才会赋值。
④处于偏向锁的对象解锁后,线程 id 仍存储于对象头中。
⑤可以加 VM 参数【-XX:-UseBiasedLocking】来禁用偏向锁。
⑥正常状态对象一开始是没有 hashCode 的,第一次调用才生成。
(2)撤销 - 调用对象 hashCode
调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销。
①轻量级锁会在锁记录中记录 hashCode
②重量级锁会在 Monitor 中记录 hashCode
在调用 hashCode 后使用偏向锁,记得去掉【-XX:-UseBiasedLocking】
(3)撤销 - 其他线程使用对象
当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁。
(4)撤销 - 调用 wait/notify
(5)批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID。
当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了,预示会再给这些对象加锁时重新偏向至锁线程。
(6)批量撤销
当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是这个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。
锁消除是发生在编译器级别的一种锁优化方式。有时候我们写的代码完全不需要加锁,却执行了加锁操作。通过编译器将其优化,将锁消除。