多线程基础(下)

简介: 多线程基础(下)

八、解决线程不安全



程序清单9:


public class Test {
    static class Counter {
        public int count = 0;
        //让 count 变量自增
        synchronized public void increase() {
            count++;
        }
    }
    static Counter counter = new Counter();
    public static void main(String[] args) {
        //线程1 自增 5w 次
        Thread t1 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };
        //线程2 自增 5w 次
        Thread t2 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(counter.count);
    }
}


我们在程序清单9 中,为 increase 方法加上了 synchronized 修饰符,可以看到输出结果就是 10000.


输出结果:


234257291dd44d4f8a4a0f6378a68f4c.png


上述解决线程不安全,就是通过 " 原子性 " 这样的切入点来解决问题,synchronized 的英文原意为 【adj. 同步的】。而它在计算机中的术语,我们可以将它理解成 " 互斥 "。


如果两个线程同时并发地尝试调用这个 synchronized 修饰的方法,此时一个线程会先执行这个方法,另外一个线程会等待,等到第一个线程方法执行完了之后,第二个线程才会继续执行这个方法。而这个过程实际上就相当于" 加锁 " 和 " 解锁 "。


举个例子,你去银行 ATM 取钱,当你进入隔间的时候,需要把门带上,而后面的人需要排队等待。这就像 synchronized 的加锁一样。synchronized 本质上就是,将 " 并发执行 " 变成 " 串行执行 ",这样一来,速度就会降低。在刚刚银行的例子中,假设你的账户有10000 元,你取出了 5000元,本来你应该剩余 5000 元的,但你取钱之后发现就剩了 1000元,这是什么感受?所以当你需要一些准确无误的结果的时候,你必须这样。那么使用 synchronized 关键字的场景实际上就是:两个线程竞争同一把锁,而可能出现阻塞的场景。这就像下图一样,一家小银行,只有一个 ATM 机。


509874d463614a06a3a8b72e02a138bd.png



1. synchronized 关键字


在 Java 中,进入 synchronized 修饰的方法,就相当于加锁;出了 synchronized 修饰的方法,就相当于解锁。如果当前是已经加锁的状态,其他的线程就无法执行这里的逻辑,就只能阻塞等待。


在上面的代码中,synchronized 被用来修饰方法,而它还能用来修饰代码块。


当它用来修饰代码块的时候,我们需要显示地在 ( ) 中指定一个加锁的对象,如果 synchronized 直接修饰的是非静态方法,相当于加锁的对象就是 this.


public void increase() {
    synchronized (this) {
        count++;
    }
}

e1ae22d61f924f57b3c64e909110d9d3.png


所谓的 " 加锁操作 " 其实就是把一个指定的锁对象中的锁标记设为 true.

所谓的 " 解锁操作 " 其实就是把一个指定的锁对象中的锁标记设为false.

如果两个线程尝试针对同一个锁对象进行加锁,此时一个线程会先获取到锁,另外一个线程就阻塞等待。这就和上面的例子差不多,假设银行只有一台 ATM,那么许多人就要排队等待。

如果两个线程,尝试针对两个不同对象进行加锁,此时两个线程都能获取到各自的锁,互不冲突。这就和银行有多台 ATM 一样,不同的人去不同的隔间取钱。


拓展:Java 中任意的对象,都可以作为 " 锁对象 ",这一点就和其他语言的设定不一样,例如 C++,Python,GO … 这些语言的加锁操作,就只能针对特定的对象加锁。


2. synchronized 主要的三个特性


(1) 互斥


互斥这一特性即对应到程序清单9 中的代码,也就是说,它解决了线程安全问题中的非原子性,即表示一个线程在执行某一步骤的过程中,在执行完之前,其他线程阻塞等待。


(2) 刷新内存,保证内存的可见性


刷新内存这一特性,指的是:synchronized 还能刷新内存,解决内存可见性的问题。举个例子:一个线程修负责改,一个负责线程读取。由于编译器的优化,可能把一些中间环节的 LOAD 和 RETURN操作取消掉了,此时读的线程可能读到的就是未修改的结果。加上 synchronized 之后,就会禁止编译器优化,保证每次进行操作的时候,都会把数据真的从内存读,也真的写回内存中。这样一来,同样地,程序运行速度会变慢,但是求得的结果和预期之间较为准确。


这解决了线程安全问题中的内存不可见性。


(3) 可重入( 防止死锁 )


对于第三点,我们对 increase 方法加了 synchronized,同时,在方法的里面,我们又加了一个 synchronized,在 Java 中,其实这样没有问题,它防止了死锁现象。


37d3042e8a9c49b98178ef237f1397c3.png


死锁:

第一次加锁,加锁成功。

第二次再尝试针对这个线程加锁的时候,此时对象头的锁标记已经是true,按照咱们之前的理解,其他线程就要阻塞等待,等待这个锁标记被改成 false,然后再重新竞争这个锁…所以说:本质上第 ② 步不会执行,因为它正在阻塞等待,那么我们就走不到最后那个花括号( 红色箭头 ),这样一来,对于第一个 synchronized 来说,我们就无法 " 解锁 ",这样就会造成死锁。


然而,在 Java 中,它就是为了防止程序员犯错,所以体现了可重入性,解决了当前逻辑的死锁现象。


3. synchronized 的使用示例


(1) 直接修饰普通方法


将 SynchronizedDemo 对象加锁


public class SynchronizedDemo {
  public synchronized void method() {
  }
}


这个时候如果两个线程并发地调用这个方法,此时是否会触发锁竞争,就要看实际的锁对象是否是同一个了。


(2) 修饰静态方法


将 SynchronizedDemo 类的对象加锁


public class SynchronizedDemo {
  public synchronized static void method() {
  }
}


由于类对象是单例的,两个线程并发调用该方法,一定会触发锁竞争。因为 static 修饰的方法直接关联到类。


(3) 修饰代码块


① 将当前对象加锁


public class SynchronizedDemo {
  public void method() {
    synchronized (this) {
    }
  }
}


② 将类的对象加锁


public class SynchronizedDemo {
  public void method() {
    synchronized (SynchronizedDemo.class) {
    }
  }
}


4. volatile 关键字


程序清单10:


public class Test {
    static class Counter {
        public int flag = 0;
    }
    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread() {
            @Override
            public void run() {
                while (counter.flag == 0){
                }
                System.out.println("循环结束...");
            }
        };
        t1.start();
        Thread t2 = new Thread() {
            @Override
            public void run() {
                Scanner scanner = new Scanner(System.in);
                System.out.println("请输入一个整数: ");
                counter.flag = scanner.nextInt();
            }
        };
        t2.start();
    }
}


输出结果:


如果正常情况下,按照上面的代码,我们输入的值为 1,即 flag 不为 0,那么就会输出

【循环结束…】,可是我们可以看到输出结果什么都没有。


8990296d03d749729348bd5c2e7730ff.png


图解分析:


edea67648b774dcf89ec42229a47d9f1.png


static class Counter {
  volatile public int flag = 0;
}


所以我们就将 flag 加上 volatile 修饰即可,一旦加上 volatile 关键字之后,此时后续针对 flag 的读写操作,就能保证一定是操作内存了。


新的输出结果:


781116ed7cde4979851b608f236c8567.png


总结:volatile 关键字用法较为单一,它只能用来修饰属性 / 成员变量。它可以保证内存可见性,但是保证不了原子性。


程序清单11:


public class Test {
    static class Counter {
        public int flag = 0;
    }
    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread() {
            @Override
            public void run() {
                while (counter.flag == 0){
                    synchronized (counter) {
                        if (counter.flag != 0) {
                            break;
                        }
                    }
                }
                System.out.println("循环结束...");
            }
        };
        t1.start();
        Thread t2 = new Thread() {
            @Override
            public void run() {
                Scanner scanner = new Scanner(System.in);
                System.out.println("请输入一个整数: ");
                counter.flag = scanner.nextInt();
            }
        };
        t2.start();
    }
}


输出结果:


b70645922d0e43558c6b9b2999223b94.png


我们可以看到,当我们使用 synchronized 关键字,也是可以保证内存可见性。


总而言之,volatile 关键字和编译器优化密切相关,而编译器优化是一个相当复杂的事情,在我们写出的代码后,编译器优化或不优化,什么时候优化、又什么时候不优化、优化到什么程度,这都是很难去控制的事情。所以还是在日常开发中,多写代码,多总结一些经验才能够慢慢熟悉。一般来说,如果某个变量,在一个线程中读,另一个线程中写,这个时候大概率需要使用 volatile.


volatile关键字 与 JMM 内存模型


volatile 这里涉及到一个重要的知识点,JMM ( Java Memory Model ) 内存模型,当代码中需要读一个变量的时候,不一定是真的在读内存。可能这个数据已经在 CPU 或 cache 中缓存着了,这个时候就可能绕过内存,直接从 CPU 或 cache 中来取这个数据。


然而,JMM 针对计算机的硬件结构又进行了一层抽象 ( 主要就是因为 Java 要考虑到跨平台的问题,要能支持不同的计算 ) 所以,


JMM 把CPU的寄存器,cache 统称为 " 工作内存 ",而工作内存一般不是真正的内存。

JMM 把真正的内存称为 " 主内存 "。


CPU 在和内存交互的时候,经常会把主内存的内容拷贝到工作内存,然后再对数据进行操作,最后才写回到主内存。而这个过程中就非常容易出现数据不一致的情况,这种情况在编译器开启优化的时候会特别严重。


而 volatile 或 synchronized 关键字能够强制保证操作数据的时候,操作的是内存,在生成的java 字节码中强制插入一些 " 内存屏障 " 的指令,而这些指令的效果,就是强制同步主内存和工作内存的内容。


5. synchronized 和 volatile 的区别和联系( 经典面试题 )


① synchronized 既能保证内存可见性,又能保证原子性。

② volatile 只能保证内存可见性,保证不了原子性。


九、wait 和 notify 方法



说明


在 Java 中,wait 和 notify 方法必须要搭配使用,才能合理地协调多个线程之间的执行先后顺序。此外, wait 和 notify 方法必须要针对同一个对象使用。wait 和 notify 都是 Object 类的方法,比如线程1中的对象1 调用了 wait 方法,必须要有个线程2 也调用对象1 的 notify 方法,才能唤醒线程1。这就是规则。

而如果是线程2,调用了对象2 的 notfiy 方法,就无法唤醒线程1.


1. wait 方法


我们使用 wait 方法,它在 Java 底层中,做了一下三件事:


(1) wait 方法让当前线程阻塞等待,因为 CPU 在进行线程调度的时候,是从就绪队列中,找一个PCB 到 CPU 上执行。所以 wait 方法就是将这个线程的 PCB 从就绪队列拿到等待对列中,并准备接受通知。


840e613831104c56be669f9f562818d1.png


(2) wait 方法释放当前锁,要想使用 wait / notify,必须搭配 synchronized,需要先获取到锁,才有资格谈 wait,所以在有锁的状态下,wait 方法其实执行了释放锁操作。释放锁的目的就是为了给其他线程让路,也就是说:释放锁之后,所有处于就绪队列的线程需要重新竞争。


举个例子:银行用户去 ATM 取钱,用户1 在取钱的时候,发现 ATM 机子里面没钱了,所以银行就叫来了工作人员,工作人员提了一大袋子,将现金放进去,然而在这期间,用户1 一定要出来,工作人员才能把钱放进去,也就是说,锁需要打开。而当工作人员将钱处理好之后,用户需要重新竞争这把锁,因为可能有存钱的、有查看存款的…


cb7e6d8867f64f4bbb3a62ae4e31de05.png


(3) 使用wait 方法,当满足一定的条件被唤醒时,重新尝试获取到这个锁。


public class Test {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        object.wait();
    }
}


输出结果:


当我们使用 wait 方法不加锁的时候,我们会发现编译器报异常,异常的英文为:非法监视状态。而 synchronized 也叫做监视器锁。


218e5db8e0d240278914f4029f60752c.png


2. notify 方法


关于 notify 的使用:


(1) notify 方法也要使用 synchronized 关键字进行加锁操作。


(2) notify 方法实际上一次只唤醒一 个线程,当有多个线程都在等待中,调用 notify 方法就相当于随机唤醒了一个线程,而其他线程都保持原状。


(3) notify 方法这是通知对方被唤醒,但调用 notify 本身的线程并不是立即释放锁,而是要等待当前的 synchronized 代码块执行完才能释放锁。


程序清单12:


public class Test {
    static class WaitTask implements Runnable {
        private Object locker = null;
        public WaitTask (Object locker) {
            this.locker = locker;
        }
        @Override
        public void run() {
            synchronized (locker) {
                System.out.println(" wait 开始...");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(" wait 结束!");
            }
        }
    }
    static class NotifyTask implements Runnable {
        private Object locker = null;
        public NotifyTask (Object locker) {
            this.locker = locker;
        }
        @Override
        public void run() {
            synchronized (locker) {
                System.out.println(" notify 开始");
                locker.notify();
                System.out.println(" notify 结束");
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //Object 对象的创建,就是为了能够方便地对线程进行加锁 / 通知操作
        Object locker = new Object();
        Thread t1 = new Thread (new WaitTask(locker));
        Thread t2 = new Thread (new NotifyTask(locker));
        t1.start();
        Thread.sleep(3000);
        t2.start();
    }
}


输出结果:


2631742ddd1743b68af12251a45ec83f.png


程序清单13:


public class Test {
    static class WaitTask implements Runnable {
        private Object locker = null;
        public WaitTask (Object locker) {
            this.locker = locker;
        }
        @Override
        public void run() {
            synchronized (locker) {
                System.out.println(" wait 开始...");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(" wait 结束!");
            }
        }
    }
    static class NotifyTask implements Runnable {
        private Object locker = null;
        public NotifyTask (Object locker) {
            this.locker = locker;
        }
        @Override
        public void run() {
            synchronized (locker) {
                System.out.println(" notify 开始");
                locker.notify();
                System.out.println(" notify 结束");
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread (new WaitTask(locker));
        Thread t2 = new Thread (new WaitTask(locker));
        Thread t3 = new Thread (new WaitTask(locker));
        Thread t4 = new Thread (new WaitTask(locker));
        Thread t5 = new Thread (new NotifyTask(locker));
        t1.start();
        t2.start();
        t3.start();
        t4.start();
        Thread.sleep(3000);
        t5.start();
    }
}


输出结果:


26e5ca58665047d8a81abaf569ab9443.png


在上图中,我们发现 4个 线程正在阻塞等待中,而只唤醒了 1个 线程,也就是说,t2,t3,t4 都在阻塞等待中,这也就说明了 notify 方法一次只能唤醒一个线程。

而 notifyAll 方法顾名思义,它的存在,就可以一次唤醒所有线程。


程序清单14:


public class Test {
    static class WaitTask implements Runnable {
        private Object locker = null;
        public WaitTask (Object locker) {
            this.locker = locker;
        }
        @Override
        public void run() {
            synchronized (locker) {
                System.out.println(" wait 开始...");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(" wait 结束!");
            }
        }
    }
    static class NotifyTask implements Runnable {
        private Object locker = null;
        public NotifyTask (Object locker) {
            this.locker = locker;
        }
        @Override
        public void run() {
            synchronized (locker) {
                System.out.println(" notifyAll 开始");
                locker.notifyAll();
                System.out.println(" notifyAll 结束");
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread (new WaitTask(locker));
        Thread t2 = new Thread (new WaitTask(locker));
        Thread t3 = new Thread (new WaitTask(locker));
        Thread t4 = new Thread (new WaitTask(locker));
        Thread t5 = new Thread (new NotifyTask(locker));
        t1.start();
        t2.start();
        t3.start();
        t4.start();
        Thread.sleep(3000);
        t5.start();
    }
}


输出结果:


db633e1ab34144afb500e88f36f283c6.png


3. notify 和 notifyAll 的区别


notify 是随机唤醒等待队列中的一个线程,其他线程还是乖乖等着。

notifyAll 是一下唤醒所有线程,但这些线程需要重新竞争锁。


十、wait 和 sleep 的区别和联系 ( 面试题 )



① sleep 操作是指定一个固定时间来阻塞等待,而 wait 既可以指定时间,也可以无限等待。


② sleep 唤醒通过时间到或 interrupt 唤醒 ,而 wait 唤醒也可以通过时间到或 interrupt 唤醒,但 wait 通常需要使用 notify 搭配


③ wait 主要的用途就是为了协调线程之间的先后顺序,这样的场景并不适合使用 sleep,sleep 只是单纯让该线程休眠,其并不涉及到多个线程的配合。


④ wait 是 Object 类的方法,而 sleep 是 Thread 类的方法。


⑤ wait 执行了释放锁操作,而sleep 不释放锁。


目录
相关文章
|
存储 Linux 调度
Linux系统编程 多线程基础
Linux系统编程 多线程基础
69 1
|
Java API 调度
并发编程系列教程(01) - 多线程基础
并发编程系列教程(01) - 多线程基础
77 0
|
7月前
|
存储 安全 Java
10分钟巩固多线程基础
10分钟巩固多线程基础
|
Java 程序员 调度
多线程(初阶)——多线程基础
多线程(初阶)——多线程基础
94 0
|
Java API 调度
并发编程之多线程基础
每个正在系统上运行的程序都是一个进程。每个进程包含一到多个线程。线程是一组指令的集合,或者是程序的特殊段,它可以在程序里独立执行。也可以把它理解为代码运行的上下文。所以线程基本上是轻量级的进程,它负责在单个程序里执行多任务。通常由操作系统负责多个线程的调度和执行。
102 0
并发编程之多线程基础
|
缓存 安全 Java
6. 多线程基础
对一个程序的运行状态, 以及在运行中所占用的资源(内存, CPU)的描述; 一个进程可以理解为一个程序; 但是反之, 一个程序就是一个进程, 这句话是错的。
100 0
6. 多线程基础
|
安全 Java 编译器
多线程基础(上)
多线程基础(上)
75 0
多线程基础(上)
|
安全 Java 调度
第8章 多线程基础
建立多线程基础,了解基本知识。
112 0
|
设计模式 缓存 安全
Java并发多线程基础总结
Java并发多线程基础总结
147 0
Java并发多线程基础总结
|
Java 编译器 调度
多线程基础知识(中)
多线程基础知识(中)
91 0
多线程基础知识(中)