共享带来的问题
两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter++;
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter--;
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("{}",counter);
}
以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析
例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存(元空间)和私有内存中进行数据交换:
这样导致线程上下文切换时,另一个线程读取的时主内存的数据,而不是另外一个线程处理后的数据。
临界区 Critical Section
临界区:一段代码块如果存在对共享资源的多线程读写操作,称这段代码块为临界区。
竞态条件:多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
阻塞时的解决方案:synchronized ,Lock
非阻塞式的解决方案: 原子变量
ps:同步和互斥的区别:
互斥是保证在临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
同步是由于线程执行的先后丶顺序不同,需要一个线程等待其它线程运行到某个点
synchronized(对象锁):它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
注意:不要错误理解为线程锁住了对象就能一直执行下去,如果cpu时间片用完,得等分配cpu时间片,但这时还是锁住的,别的线程进不来。
方法上synchronized的区别
//成员方法锁住this对象
class Test{
public synchronized void test() {
}
}
等价于
class Test{
public void test() {
synchronized(this) {
....
}
}
}
//静态方法锁住类对象
class Test{
public synchronized static void test() {
}
}
等价于
class Test{
public static void test() {
synchronized(Test.class) {
}
}
}
“线程8锁”,考察锁的对象
class Number{
public synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}
由于n1和n2是两个不同的对象,对应堆中的实体对象是不同的,堆中实体对象不同,则指向方法区的对象实体数据不同(this指向运行时常量池的某块数据)。所以不影响两个线程同时运行。
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
}
线程1锁的是类对象,线程2锁的是this对象。所以不影响两个线程同时运行。
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public static synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}
方法a和b都锁住Number.class对象,所以线程1和2互斥。
变量的线程安全分析
成员变量和静态变量是否线程安全:
如果它们没有共享,则线程安全<br /> 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况<br /> 如果只有读操作,则线程安全<br /> 如果有读写操作,则这段代码是临界区,需要考虑线程安全
局部变量是否线程安全:
一般情况下局部变量是线程安全的<br /> 但局部变量引用的对象则未必:
- 如果对象仅在方法内创建、使用、消亡,则是线程安全的;
如果一个对象由外部传入,或者传出外部,则需要考虑线程安全问题(外部仅读,线程安全;外部有读写—如果不考虑同步机制的话,会存在线程安全问题)
class Number{
public void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
method2(list);
method3(list);
System.out.println("父类执行完后的list:"+list);
}
}
private void method2(ArrayList<String> list) {
list.add("1");
}
void method3(ArrayList<String> list) {
System.out.println("原来");
list.remove(0);
}
}
public class ThreadSafeSubClass extends Number{
public static void main(String[] args) {
new ThreadSafeSubClass().method1(1);
}
public void method1(int loopNum){
super.method1(loopNum);
}
@Override
public void method3(ArrayList<String> list) {
System.out.println(list);
new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
list.add("1");
System.out.println("执行线程");
System.out.println(list+"重写后");
}).start();
}
}
在子类调用父类的method1()方法,那么父类的method1()方法创建一个list对象,传给了父类的method2()方法使用,还传给了子类的method3()使用,子类的方法创建了一个新线程使用这个list对象(局部变量),那么线程安全问题就出现了。
其实可以在父类的method3()创建线程来使用list对象,同样的效果。
子类重写父类方法是想说明, private 或 final 提供【安全】的意义所在。
1.父类中的方法被private修饰,子类中也定义了一个跟父类一样的方法
2.父类中的方法被final修饰,子类中重写了这个方法
3.父类中的方法同时被private和final修饰,子类中也定义了一个跟父类一样的方法
针对于上面三种不同的情况,所产生不同的结果,在此进行总结:
①父类中被private修饰的方法表示仅在该类可见,所以子类没有继承到父类的private方法,因此,若子类定义了一个与父类的private方法相同的方法名和参数列表也是没问题的,相当于子类自己定义了一个新的方法;
③需要注意的点:若父类中的方法是既被private修饰也被final修饰了,那么说明该方法是不会被子类继承,此时子类定义相同的方法也没有问题,不再产生重写与final的矛盾,而是在子类中定义了新的方法。
关于 子类局部变量 和 父类局部变量 的关系
class Fu {
int i = 2;
public int getI() {
return i;
}
}
class Zi extends Fu {
int i = 4;
}
public class Jicheng {
public static void main(String[] args) {
System.out.println(new Zi().getI());
}
}
输出结果为2?
子类不是应该 也继承了父类的getI()方法么,
怎么get到的是父类的值?
子类继承父类,会继承父类的所有属性(properties)和方法(methods),包括private修饰的属性和方法,但是子类只能访问和使用非private的,所以可以这么理解: 子类对象的内部 包涵了一个完整父类对象。
new Zi()就是创建一个子类对象,而子类对象内部包涵了父类对象,所以又要先new Fu(), 也就是说创建子类对象 = 创建父类对象 + 其他
子类对象没有重写(Overriding)父类的方法,那么这个方法就还”包涵”在父类对象里,子类对象用getI()方法,其实质调用的是 子类对象”肚子里的”那个父类对象的方法。
JAVA规定,变量前面没有特别说明是谁的变量,那么就适用”就近原则”,显然父类对象的属性int i是最近的
线程安全类
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent包下的类
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。
但注意它们多个方法的组合不是原子的。
Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
table.put("key", value);
}
String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的。有同学或许有疑问,String 有 replace,substring 等方法【可以】改变值啊,那么这些方法又是如何保证线程安全的呢?
以下是String的substring方法源码
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
} else {
int subLen = this.length() - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
} else if (beginIndex == 0) {
return this;
} else {
return this.isLatin1() ? StringLatin1.newString(this.value, beginIndex, subLen) : StringUTF16.newString(this.value, beginIndex, subLen);
}
}
}
通过new出来新的字符串这个操作来保证不可变性。
练习:
public class 卖票练习 {
public static void main(String[] args) throws InterruptedException {
//模拟多人买票
TicketWindow ticketWindow = new TicketWindow(1000000000);
List<Integer> amountList = new ArrayList<>();
List<Thread> threadList=new ArrayList<>();
for (int i = 0; i < 20000; i++) {
Thread thread = new Thread(()->{
int amout=ticketWindow.sell(randomAmount());//买票
amountList.add(amout);
});
threadList.add(thread);
thread.start();
}
for (Thread thread:threadList
) {thread.join(); //让每个线程都排到main线程前面
}
//统计卖出的票数和剩余票数相加是否等于总票数
System.out.println(ticketWindow.getCount()+amountList.stream().mapToInt(i-> i).sum());
}
static Random random = new Random();
public static int randomAmount(){return random.nextInt(5)+1;}
}
class TicketWindow {
private int count;
public TicketWindow(int count) {
this.count = count;
}
public int getCount() {
return count;
}
public int sell(int amount) {
if (this.count >= amount) {
this.count -= amount;
return amount;
} else {
return 0;
}
}
}
特别留意多线程执行的代码,9~10行,有两个竞争条件。
- amountList
- randomAmount() —->买号票
这两个条件的竞争是线程不安全的。
正确修改:
public synchronized int sell(int amount) {
if (this.count >= amount) {
this.count -= amount;
return amount;
} else {
return 0;
}
}
List<Integer> amountList = new Vector<>();
Monitor 概念
Java对象头
首先补充以下对象的内存布局(具体在jvm篇有讲)
举例
public class Customer{
int id = 1001;
String name;
Account acct;
{
name = "匿名客户";
}
public Customer() {
acct = new Account();
}
}
public class CustomerTest{
public static void main(string[] args){
Customer cust=new Customer();
}
}
图示
以 32 位虚拟机为例
说明:若与Monitor关联成功则 mark word变为62位指针(指向 Monitor)+2 位00/10
Monitor 被翻译为监视器或管程(操作系统叫管程)
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针(+两位来表示锁类型)
Monitor 结构如下:
- 刚开始 Monitor 中 Owner 为 null
- 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一 个 Owner
- 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList BLOCKED
- Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的
- 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲 wait-notify 时会分析
注意:
synchronized 必须是进入同一个对象的 monitor 才有上述的效果
不加 synchronized 的对象不会关联监视器,不遵从以上规则
synchronized 原理
public class test {
static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
}
对应的字节码:
说明:如果6-16行执行没有发生异常,那么直接到24,即return。
如果发生异常,则到19行,进行异常处理,注意的是异常后还是会主动将锁释放
synchronized原理进阶
1.轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以jvm会使用轻量级锁来优化。
轻量级锁对使用者是透明的,即语法仍然是 synchronized
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
- 创建锁记录(Lock Record)对象(属于栈帧中的附加信息),每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word
- 让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录
- 如果 cas 替换成功,对象头中存储了 锁记录地址和状态00(代表轻量级锁),表示由该线程给对象加锁,这时图示如下
- 如果 cas 失败,有两种情况
- 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
- 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数
- 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
- 当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
- 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
- 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程
- 即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址
- 然后自己进入 Monitor 的 EntryList BLOCKED
- 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁 流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程 。(cas失败后对象的mark word变成有关锁的记录,cas到Lock Record的Mark Word随着栈帧消失,但这并不影响,这些信息都是与对象自身定义无关的数据,在运行期间 Mark Word 里存储的数据会随着锁标志位的变化而变化。)
接下来Thread-0释放锁,唤醒EntryList中阻塞的线程,这些线程开始竞争锁。
但有一个优化细节,重量级锁竞争的时候,还可以使用自旋来进行优化(其实在轻量级锁进行cas对象的mark word时如果其它线程正在使用,也会进行一定次数的自旋尝试),如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
在java 6之后自旋锁时自适应的 ,比如对象刚刚的一次自旋操作成功后,那么认为这次自旋成功的可能性较高,就会多自旋几次;反之,就少自旋甚至不自旋,比较智能(默认开启)
2.偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。
如果在运行过程中,遇到了其他线程抢占锁,等待原持有偏向锁的线程到安全点,持有偏向锁的线程会被挂起,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,撤销偏向锁,设置为无锁(标志位为01)或轻量级锁(标志位为00)的状态。(偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。)
如果为活动状态,即还在同步代码块中,则设置为轻量级锁状态,然后互相cas对象mark word到Lock Record 地址,Lock Record指针指向对象锁记录(同轻量级锁一样),然后将挂起 状态的线程(原持有偏向锁的线程到安全点,所以可以挂起后从安全点继续运行)继续运行,后面流程就跟轻量级锁一样了。
更新一下原有的对象头结构:
- 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的 thread、epoch、age 都为 0
- 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -
XX:BiasedLockingStartupDelay=0 来禁用延迟
- 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、 age 都为 0,第一次用到 hashcode 时才会赋值
测试偏向锁
// 添加虚拟机参数 -XX:BiasedLockingStartupDelay=0
public class test {
public static void main(String[] args) {
Cat cat = new Cat();
ClassLayout classLayout = ClassLayout.parseInstance(cat);
new Thread(() -> {
System.out.println("synchronized 前");
System.out.println(classLayout.toPrintable());
synchronized (cat) {
System.out.println("synchronized 中");
System.out.println(classLayout.toPrintable());
}
System.out.println("synchronized 后");
System.out.println(classLayout.toPrintable());
}, "t1").start();
}
}
public class Cat {
}
我这也没使用synchronized关键字呀,那不也应该是无锁么?怎么会是偏向锁呢?仔细看一下偏向锁的组成,对照输出结果红色划线位置,你会发现占用 thread 和 epoch 的 位置的均为0,说明当前偏向锁并没有偏向任何线程。此时这个偏向锁正处于可偏向状态,准备好进行偏向了!你也可以理解为此时的偏向锁是一个特殊状态的无锁。
当使用了synchronized关键字:
对象头内容有了明显的变化,当前偏向锁偏向主线程。
当退出了synchronized关键字代码块,偏向锁还是锁着主线程,说明线程不会主动释放偏向锁的。
但有些情况线程会主动释放偏向锁,现在讲下偏向锁的撤销。
偏向锁的撤销
撤销 - 调用对象 hashCode
- 当一个对象当前正处于偏向锁状态,并且需要计算其identity hash code的话,则它的偏向锁会被撤销,并且锁会膨胀为重量锁;
- 重量锁的实现中, Monitor类里有字段可以记录非加锁状态下的mark word,其中可以存储identity hash code的值。或者简单说就是重量锁/轻量锁 可以存下identity hash code。
请一定要注意,这里讨论的hash code都只针对identity hash code。用户自定义的hashCode()方法所返回的值跟这里讨论的不是一回事。
还是上面的代码
{
...
cat.hashCode();
System.out.println("synchronized 后");
System.out.println(classLayout.toPrintable());
}, "t1").start();
}
线程主动释放偏向锁变无锁状态了
撤销 - 其它线程使用对象
当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
public class test {
public static void main(String[] args) {
Cat cat = new Cat();
ClassLayout classLayout = ClassLayout.parseInstance(cat);
Thread t2 = new Thread(()->{
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (cat){
System.out.println("我来锁住cat对象了");
}
});
t2.start();
new Thread(() -> {
System.out.println("synchronized 前");
System.out.println(classLayout.toPrintable());
synchronized (cat) {
System.out.println("synchronized 中");
System.out.println(classLayout.toPrintable());
}
try {
t2.join(); //等t2锁住对象后再执行
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("等t2 线程 synchronized 后,t1线程的偏向锁状态:");
System.out.println(classLayout.toPrintable());
}, "t1").start();
}
}
前中还是跟之前一样
撤销 - 调用 wait/notify
线程1将cat对象锁住并进行wait(),线程2将cat对象锁住并进行notify()
批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的Thread ID 。当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程
public class test {
public static void main(String[] args) {
Vector<Dog> list = new Vector<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 30; i++) {
Dog d = new Dog();
list.add(d);
synchronized (d) {
System.out.println(ClassLayout.parseInstance(d).toPrintable());
}
}
synchronized (list) {
list.notify(); //防止t2线程对list集合进行影响
}
}, "t1");
t1.start();
Thread t2 = new Thread(() -> {
synchronized (list) {
try {
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("=============================================================");
for (int i = 0; i < 30; i++) {
if(i==19){
System.out.println("---------------------开始变化,剩下的偏向锁都指向t2线程---------------------");
}
Dog d = list.get(i);
System.out.println("t2线程加锁前: "+ClassLayout.parseInstance(d).toPrintable());
synchronized (d) {
System.out.println("t2线程加锁后: "+ClassLayout.parseInstance(d).toPrintable());
}
}
}, "t2");
t2.start();
}
}
t1线程将30个dog对象都加上偏向锁—->指向t1线程
当t2线程将前20个dog对象加锁时,t1线程放弃偏向锁,偏向锁变为轻量级锁指向t2线程
当t2线程将后20个dog对象加锁时,原本指向t1线程的偏向锁,指向了t2
批量撤销
当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象 都会变为不可偏向的,新建的对象也是不可偏向的。
3.锁消除
锁消除是Java虚拟机在JIT编译期间,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。如果逃逸分析发现对象是非逃逸的,编译器就可以自行消除同步。
wait/notify
- Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
- BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
- BLOCKED 线程会在 Owner 线程释放锁时唤醒
- WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入 EntryList 重新竞争
常用API
- obj.wait() 让进入 object 监视器的线程到 waitSet 等待 (放弃锁)
- obj.notify() 在 object 上正在 waitSet 等待的线程中挑一个唤醒
- obj.notifyAll() 让 object 上正在 waitSet 等待的线程全部唤醒
注意:它们都属于Object对象的方法。必须获得此对象的锁,才能调用这几个方法。
关键:waiting的线程被调用 notify()唤醒后仍需要 进入entrylist重新竞争
补充:sleep(long n) 和 wait(long n) 的区别
1) sleep 是 Thread 方法,而 wait 是 Object 的方法
2) sleep 不需要强制和 synchronized 配合使用,但 wait 需要 和 synchronized 一起用
3) sleep 在睡眠的同时,不会释放对象锁的,但 wait 在等待的时候会释放对象锁
4) 它们状态都是 TIMED_WAITING
wait/notify的 正确使用姿势
synchronized(lock) {
while(条件不成立) {
lock.wait();
}
// 干活
}
//另一个线程
synchronized(lock) {
lock.notifyAll();
}
保护性暂停模式(同步):
即 Guarded Suspension,用在一个线程等待另一个线程的执行结果。
有一个结果需要从一个线程传递到另一个线程,让他们关联同一个 GuardedObject
如果有结果不断从一个线程到另一个线程那么可以使用消息队列(见生产者/消费者)
JDK 中,join 的实现、Future 的实现,采用的就是此模式。因为要等待另一方的结果,因此归类到同步模式。
public class 同步模式之保护性暂停 {
//线程1等下线程2下载结果
public static void main(String[] args) {
GuardedObject guardedObject = new GuardedObject();
new Thread(()->{
//等待结果
try {
System.out.println("等待结果");
int o =(int) guardedObject.get();
System.out.println("结果是:"+o);
} catch (InterruptedException e) {
e.printStackTrace();
}
},"t1").start();
new Thread(()->{
System.out.println("执行下载");
guardedObject.complete(1);
},"t2").start();
}
}
class GuardedObject{
//结果
private Object response;
//获取结果
public Object get() throws InterruptedException {
synchronized (this){
while (response==null){
this.wait();
}
return response;
}
}
//产生结果
public void complete(Object response){
synchronized (this){
this.response=response;
this.notifyAll();
}
}
}
带超时版的get
public Object get(long millis) {
synchronized (lock) {
// 1) 记录最初时间
long begin = System.currentTimeMillis();
// 2) 已经经历的时间
long timePassed = 0;
while (response == null) {
// 4) 假设 millis 是 1000,结果在 400 时唤醒了,那么还有 600 要等
long waitTime = millis - timePassed;
log.debug("waitTime: {}", waitTime);
if (waitTime <= 0) {
log.debug("break...");
break; }
try {
lock.wait(waitTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 3) 如果提前被唤醒,这时已经经历的时间假设为 400 (关键)
timePassed = System.currentTimeMillis() - begin;
log.debug("timePassed: {}, object is null {}",
timePassed, response == null);
}
return response; }
}
join原理其实就是保护性暂停模式的实现
public final synchronized void join(long millis)
throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0;
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
if (millis == 0) {
while (isAlive()) {
wait(0);
}
} else {
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}
这是最终走进的方法,当isAlive,就wait。注意当前锁住是整个对象。我们在A线程中调用B线程的Join方法,也就是B线程充当了这把锁,但调用者是A线程,也就是说挂起来的是A线程(谁调用wait谁休眠),当B线程还活着,就一直wait,只有当B线程执行完了,才会被唤醒,所以易推测出当B线程执行完毕会有一个收尾工作:使用notify方法,不然A线程就会一直挂着了,此代码可以在JVM源码中看到。
生产者/消费者(异步)
- 与前面的保护性暂停中的 GuardObject 不同,不需要产生结果和消费结果的线程一一对应
- 消费队列可以用来平衡生产和消费的线程资源
- 生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
- 消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
- JDK 中各种阻塞队列,采用的就是这种模式
public class 消息队列 {
public static void main(String[] args) {
MessageQueue messageQueue = new MessageQueue(2);
for (int i = 0; i < 3; i++) {
int id=i;
new Thread(()->{
try {
messageQueue.put(new Message(id,"值"+id));
} catch (InterruptedException e) {
e.printStackTrace();
}
},"线程"+i).start();
}
new Thread(()->{
try {
while (true){
Thread.sleep(1000);
Message take = messageQueue.take();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
},"消费者").start();
}
}
class MessageQueue{
private LinkedList<Message> list=new LinkedList<>();
private int capcity;//容量
public MessageQueue(int capcity) {
this.capcity = capcity;
}
public Message take() throws InterruptedException {
synchronized (list){
while (list.isEmpty()){
{
System.out.println("队列为空等待生产");
list.wait();
}
}
Message message = list.removeFirst();
System.out.println("已消费消息 内容:"+message.getValue());
list.notifyAll();//告诉put有信息消费了可以生产信息了
return message;//返回消息并删除
}
}
public void put(Message message) throws InterruptedException {
synchronized (list){
while (list.size()==capcity){
System.out.println("队列已满等待消费");
list.wait();
}
list.add(message);
System.out.println("已生产消息 内容"+message.getValue());
list.notifyAll();//告诉take有信息进来了可以消费信息了
}
}
}
Park & Unpark
它们是 LockSupport 类中的方法
// 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark(暂停线程对象)
Thread t1 = new Thread(() -> {
log.debug("start...");
sleep(2);
log.debug("park...");
LockSupport.park();
log.debug("resume...");
}, "t1");
t1.start();
sleep(1);
log.debug("unpark...");
LockSupport.unpark(t1);
先执行unpark,再执行park,不了解原理的会很容易认为park住线程了,但并不会。
18:43:50.765 c.TestParkUnpark [t1] - start...
18:43:51.764 c.TestParkUnpark [main] - unpark...
18:43:52.769 c.TestParkUnpark [t1] - park...
18:43:52.769 c.TestParkUnpark [t1] - resume..
先讲一下LockSupport 的 park(底层用Unsafe类) 与 Object 的 wait & notify 相比
- wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 park,unpark 不必
- park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程,就不那么【精确】
- park & unpark 可以先 unpark,而 wait & notify 不能先 notify。
原理之 park & unpark
每个线程都有自己的一个 Parker 对象,由三部分组成 _counter, _cond 和 _mutex 打个比喻。
线程就像一个旅人,Parker 就像他随身携带的背包,条件变量就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足)
调用 park 就是要看需不需要停下来歇息
- 如果备用干粮耗尽,那么钻进帐篷歇息
- 如果备用干粮充足,那么不需停留,继续前进
调用 unpark,就好比令干粮充足
- 如果这时线程还在帐篷,就唤醒让他继续前进
- 如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进
因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮
- 当前线程调用 Unsafe.park() 方法
2. 检查 _counter ,本情况为 0,这时,获得 _mutex 互斥锁
3. 线程进入 _cond 条件变量阻塞 Thread-0线程
4. 设置 _counter = 0
1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
2. 唤醒 _cond 条件变量中的 Thread_0
3. Thread_0 恢复运行
4. 设置 _counter 为 0
多把锁造成的 死锁,活锁,饥饿 问题(了解)
死锁
有这样的情况:一个线程需要同时获取多把锁,这时就容易发生死锁。
t1 线程 获得 A对象 锁,接下来想获取 B对象 的锁 t2 线程 获得 B对象锁,接下来想获取 A对象的锁
public class test {
public static void main(String[] args) {
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
synchronized (A) {
System.out.println("lock A");
try {
TimeUnit.SECONDS.sleep(1);
synchronized (B) {
System.out.println("lock B");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "t1");
Thread t2 = new Thread(() -> {
synchronized (B) {
System.out.println("lock B");
synchronized (A) {
System.out.println("lock A");
}
}
}, "t2");
t1.start();
t2.start();
}
}
定位死锁
检测死锁可以使用 jconsole工具,或者使用 jps 定位进程 id,再用 jstack 定位死锁
另外如果由于某个线程进入了死循环,导致其它线程一直等待,对于这种情况 linux 下可以通过 top 先定位到 CPU 占用高的 Java 进程,再利用 top -Hp 进程id 来定位是哪个线程,最后再用 jstack 排查。
哲学家就餐问题
执行了一会就执行不下去,发生了死锁。
最优解决方法在后面ReentrantLock马上会讲。
活锁
活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,例如
public static void main(String[] args) {
new Thread(() -> {
// 期望减到 0 退出循环
while (count > 0) {
sleep(0.2);
count--;
log.debug("count: {}", count);
}
}, "t1").start();
new Thread(() -> {
// 期望超过 20 退出循环
while (count < 20) {
sleep(0.2);
count++;
log.debug("count: {}", count);
}
}, "t2").start();
}
饥饿
如果线程优先级“不均”,在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。解决“饥饿”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
ReentrantLock
相对于 synchronized 它具备如下特点
- 可中断
- 可以设置超时时间
- 可以设置为公平锁 (防止饥饿)
- 支持多个条件变量 (可叫醒 因某条件处于waiting set的线程,不同synchronized叫醒全部处于waitig set线程)
- 与 synchronized 一样,都支持可重入(重复获得锁,如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住)
语法跟Unsafe.park()一样
// 获取锁
reentrantLock.lock();
try {
// 临界区
} finally {
// 释放锁
reentrantLock.unlock();
}
可打断(被动)
@Slf4j
public class test {
ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
log.debug("启动...");
try {
lock.lockInterruptibly();
} catch (InterruptedException e) {
e.printStackTrace();
log.debug("等锁的过程中被打断");
return;
}
try {
log.debug("获得了锁");
} finally {
lock.unlock();
}
}, "t1");
lock.lock(); //main线程获取锁
log.debug("获得了锁");
t1.start();
try {
sleep(1);
t1.interrupt();
log.debug("执行打断");
} finally {
lock.unlock();
}
}
结果:
18:02:40.520 [main] c.TestInterrupt - 获得了锁
18:02:40.524 [t1] c.TestInterrupt - 启动...
18:02:41.530 [main] c.TestInterrupt - 执行打断
java.lang.InterruptedException
at
java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireInterruptibly(AbstractQueuedSynchr
onizer.java:898)
at
java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireInterruptibly(AbstractQueuedSynchron
izer.java:1222)
at java.util.concurrent.locks.ReentrantLock.lockInterruptibly(ReentrantLock.java:335)
at cn.itcast.n4.reentrant.TestInterrupt.lambda$main$0(TestInterrupt.java:17)
at java.lang.Thread.run(Thread.java:748)
18:02:41.532 [t1] c.TestInterrupt - 等锁的过程中被打断
注意,这里的ReentrantLock设置的是可中断锁 lock.lockInterruptibly()
锁超时(主动)
立刻失败:
....
Thread t1 = new Thread(() -> {
log.debug("启动...");
if (!lock.tryLock()) {
log.debug("获取立刻失败,返回");
return;
}
try {
log.debug("获得了锁");
} finally {
lock.unlock();
}
}, "t1");
....
线程尝试获取锁,失败则立刻返回。lock.tryLock()
还可以设置超时失败,在tryLock()上设置参数(单位+时间)
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
公平锁
ReentrantLock 默认是不公平的,每个线程抢占锁的顺序不定,谁运气好,谁就获取到锁,和调用lock方法的先后顺序无关。
公平锁一般没有必要,会降低并发度,后面分析原理时会讲解
条件变量
synchronized 中也有条件变量,就是我们讲原理时那个 waitSet 休息室,当条件不满足时进入 waitSet 等待 。
ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的,这就好比
synchronized 是那些不满足条件的线程都在一间休息室等消息 ,而 ReentrantLock 支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒
使用要点:
- await 前需要获得锁
- await 执行后,会释放锁,进入 conditionObject 等待
- await 的线程被唤醒(或打断、或超时)取重新竞争 lock 锁
- 竞争 lock 锁成功后,从 await 后继续执行
同步模式之顺序控制
1.固定运行顺序
比如,必须先 2 后 1 打印
Thread t1 = new Thread(() -> {
try { Thread.sleep(1000); } catch (InterruptedException e) { }
// 当没有『许可』时,当前线程暂停运行;有『许可』时,用掉这个『许可』,当前线程恢复运行
LockSupport.park();
System.out.println("1");
});
Thread t2 = new Thread(() -> {
System.out.println("2");
// 给线程 t1 发放『许可』(多次连续调用 unpark 只会发放一个『许可』)
LockSupport.unpark(t1);
});
t1.start();
t2.start();
2.交替输出
比如,交替输出abc
public class test {
public static void main(String[] args) {
AwaitSignal as = new AwaitSignal(5);
Condition aWaitSet = as.newCondition();
Condition bWaitSet = as.newCondition();
Condition cWaitSet = as.newCondition();
as.start(aWaitSet);
new Thread(() -> {
as.print("a", aWaitSet, bWaitSet);
}).start();
new Thread(() -> {
as.print("b", bWaitSet, cWaitSet);
}).start();
new Thread(() -> {
as.print("c", cWaitSet, aWaitSet);
}).start();
}
}
class AwaitSignal extends ReentrantLock {
public void start(Condition first) {
this.lock();
try {
System.out.println("start");
first.signal();
} finally {
this.unlock();
}
}
public void print(String str, Condition current, Condition next) {
for (int i = 0; i < loopNumber; i++) {
this.lock();
try {
current.await(); //a休息室等待,经过start方法后a休息室被呼叫
System.out.println(str);
next.signal(); //呼叫b休息室
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
this.unlock();
}
}
}
// 循环次数
private int loopNumber;
public AwaitSignal(int loopNumber) {
this.loopNumber = loopNumber;
}
}
解决哲学家就餐问题
其实很简单,就是使用ReentrantLock的tryLock()方法
public class 哲学家就餐问题 {
public static void main(String[] args) {
Chopstick c1 = new Chopstick("1");
Chopstick c2 = new Chopstick("2");
Chopstick c3 = new Chopstick("3");
Chopstick c4 = new Chopstick("4");
Chopstick c5 = new Chopstick("5");
new Philosopher("苏格拉底", c1, c2).start();
new Philosopher("柏拉图", c2, c3).start();
new Philosopher("亚里士多德", c3, c4).start();
new Philosopher("赫拉克利特", c4, c5).start();
new Philosopher("阿基米德", c5, c1).start();
}
}
class Philosopher extends Thread{
Chopstick left;
Chopstick right;
public Philosopher(String name, Chopstick left, Chopstick right) {
super(name);
this.left = left;
this.right = right;
}
private void eat() throws InterruptedException {
System.out.println(super.getName()+"eating...");
Thread.sleep(1000);
}
@Override
public void run() {
while (true) {
// 获得左手筷子 如果拿不到左手筷子 那就先放下来,等待下一次循环再尝试拿
if(left.tryLock()){
// 获得右手筷子 如果拿不到右手筷子 那就先放下来,等待下一次循环再尝试拿
try {
if(right.tryLock()){
try {
eat(); //都拿到了
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
right.unlock();
}
}
}finally {
left.unlock();
}
}
}
}
}
class Chopstick extends ReentrantLock {
String name;
public Chopstick(String name) {
this.name = name;
}
@Override
public String toString() {
return "筷子{" + name + '}';
}
}