JAVA多线程(第三期)

synchronized的其他属性

举个例子:

public class Thread17 {
    public static void main(String[]args){
        Object lock = new Object();
        Thread t = new Thread(()->{
            synchronized (lock){
                synchronized (lock){
                    System.out.println("hello!");
                }
            }
        });
        t.start();
    }
}

直观上好像代码中都线程会出现阻塞,但事实上它仍可以打印出“hello”,原因是这两次加锁,其实是在同一个线程进行的。当前由于是同一个线程,此时锁对象就知道了第二次加锁的线程就是持有锁的线程,第二次操作,就可以直接放行通过,不会出现阻塞,这个特性称为“可重入”。但是只有java中的锁才有这样的特性,隔壁C++就不行........

对于可重入锁来说,内部会有两个信息:1.当前这个锁是被哪个线程持有的,2.加锁次数的计数器。

对于例子来说,第二次加锁时发现是同一个线程,就只是++计数器,没别的操作了。

由于加了两次锁,使用计数器为2,第一次解锁计数器-1,但不为0,仍然不会解锁线程,第二次解锁时计数器为0,这时才是真正的解锁了。也就是说,计数器是否为0,才是判断一个线程是否解锁的关键条件。

死锁问题(经典面试题)

死锁是多线程的一类经典问题,如果加锁方式不当,可能会导致死锁问题。

死锁的三种经典场景:

1)一个线程,一把锁。

如果一个线程加锁两次,就会导致死锁问题。(java中一般不会).

2)两个线程,两把锁。

线程1获取锁1,线程2获取锁2,接下来,线程1尝试获取锁2,线程2尝试获取锁1,就会僵持。

Object A = new Object();
        Object B = new Object();
        Thread t1 = new Thread(()->{
            synchronized (A){
                try{
                    Thread.sleep(1000);
                }catch (InterruptedException e){
                    e.printStackTrace();
                }
                synchronized (B){
                    System.out.println("t1得到了两把锁");
                }
            }
        });
        Thread t2 = new Thread(()->{
            synchronized (B){
                try{
                    Thread.sleep(1000);
                }catch (InterruptedException e){
                    e.printStackTrace();
                }synchronized (A){
                    System.out.println("t2得到了两把锁");
                }
            }
        });
        t1.start();
        t2.start();

上面就是一个典型的死锁情况。t1在占有A的情况等待B,t2在占有B的情况下等待A,这样一直运行也是没有结果的。

3)N个线程M把锁

经典问题引入:哲学家就餐问题,和操作系统中的哲学家就餐问题一样。

特殊情况:每个哲学家在同一时刻都要吃,都拿起了一根筷子,导致死锁。

解决方法:首先明确死锁产生的条件:1)互斥使用。获取锁的过程是互斥的,一个线程拿到了这把锁,另一个也想获取,就需要阻塞等待。2)不可抢占。一个线程拿到了锁之后,只能主动解锁,不能让别的线程强走锁。3)请求保持。一个线程拿到锁A之后,在持有A的前提下,尝试获取B。4)环路等待。A拥有1等2,B拥有2等1........

要解决死锁问题,就要打破上述任意一个条件,很显然,1和2不好破坏,3不一定,所以4是最好破坏的,只要指定一定的规则,就可以有效破坏环路问题:

指定加锁顺序,针对五把锁,都进行编号,约定每个线程获取锁时,一定要先获取编号小的锁,后获取编号大的锁。

JAVA标准库中的线程安全类

ArrayList,LinkedList,HashMap,TreeMap,HashSet,TreeSet,StringBuilder都是线程不安全的类,

Vector(不推荐),HashTable(不推荐,标准库即将弃用),ConcurrentHashMap,StringBuffer这几个类自带锁,在多线程情况下相对安全。

内存可见性引起的线程安全问题

如果一个线程写,一个线程读,这也可能存在线程不安全问题。

public class Thread19 {
    private static int flag = 0;

    public static void main(String[]args){
        Thread t1 = new Thread(()->{
            while(flag==0){

            }
            System.out.println("线程t1结束");
        });
        Thread t2 = new Thread(()->{
            System.out.println("请输入:");
            Scanner sc = new Scanner(System.in);
            flag = sc.nextInt();

        });
        t1.start();
        t2.start();
    }
}

在例子中,理论上只要输入不为0的值,t1就应该结束,但实际上并不会结束。

原因是因为t1的while循环有两条核心指令:1)Loda读取内存中的flag值到cpu的寄存器里。2)拿着寄存器的值和0进行比较。

因为执行速度非常快,所以会反复执行1和2指令。

在执行过程中,1)load操作执行的结果都是一样的(因为用户输入有时间,在这个等待输入时间,已经执行了百亿次)。2)load的操作开销远远大于条件跳转(第二条指令),访问寄存器的速度远远大于访问内存。在等待输入时间中,频繁执行load和条件跳转,load的开销大,而且load的结果没有变化,此时jvm便会怀疑load是否有存在的必要。此时jvm便会进行代码优化,把load操作优化掉,优化掉后,便不会重复读内存,直接使用寄存器之前“缓存”的值,大幅度提高代码执行速度。而且多线程情况下,jvm代码优化很容易发生bug,所以即使使用Scanner修改了flag,jvm也会误判为flag仍为0。就相当于t2修改了内存,但t1没有看到这个变化,就称为“内存可见性问题”。但内存可见性问题高度依赖代码优化,如果代码不触发优化,就不一定产生内存可见性问题,比如我们向while循环中添加一些指令,使得loda执行次数没有这么多次,就有可能不会产生内存可见性问题。

针对这个问题,java提供了volatile,可以确保代码优化被强制关闭,强制重新从内存中读取数据。

用法是给变量前加上volatile。

 private volatile static int flag = 0;
wait和notify

wait(等待),notify(通知)和join类似,引入wait和notify就是为了从应用层面上,干预到多个不同线程代码的执行顺序,可以让先执行的代码把代码执行完,后调动的代码放弃执行。

应用情况:当某个线程拿到锁后,发现并没有自己需要的资源,但由于线程的随机调度,该线程仍可能竞争到锁,而且概率还比较大,因为它不需要唤醒,导致需要锁的线程处于BLOCKED状态,导致线程饥饿。

wait内部做了三件事:

1)释放锁;2)进入阻塞等待;3)当其他线程调用notify时,wait解除阻塞,并重新获取到锁。

注意:wait要在synchronized里使用,因为只有拿到锁才能释放锁。

java约定notify也要放到synchronized中,

public class Thread20 {
    public static void main(String[]args) throws InterruptedException {
        Object ob = new Object();

        Thread t = new Thread(()->{
            synchronized (ob){
                System.out.println("before wait");
                try {
                    ob.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("after wait");
            }
        });
        Thread t1 = new Thread(()->{
            try {
                Thread.sleep(2000);
                synchronized (ob){
                    System.out.println("before t1 notify");
                    ob.notify();
                    System.out.println("after t1 notify");
                }
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        });
        t1.start();
        t.start();
    }
}

sleep(2000)是为来先让t线程拿到锁,结果就是t先拿到锁,之后wait,直到t1执行完notify。

before wait
before t1 notify
after t1 notify
after wait

注意点:1)wait和join类似,也有带有超时时间的版本。

2)wait和notify必须是同一个对象,如果两个wait是同一个对象调用的,则随机唤醒一个。

3)notifyAll,唤醒这个对象上所有等待的线程。假设有多个线程,都使用同一个对象wait,针对这个对象notifyAll,此时就会全部唤醒。但是全部唤醒后,它们还是要竞争........

经典面试题:wait和sleep的区别

wait提供了一个带有超时时间的版本,sleep也是能指定时间;都是到时间就继续执行,解除阻塞。wait和sleep都可以被提前唤醒,wait通过notify唤醒,sleep通过interrupt唤醒。但使用wait,最主要的目标,一定是在不知道要等多少时间的情况下使用的,所谓的超时时间,其实是兜底的。

使用sleep,一定是在知道要等多少时间的情况下使用的,虽然能提前唤醒,但是是通过异常唤醒的,不是一个正常情况。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值