Java并发编程01:Java多线程基础(synchronized,volatile,wait/notify)

线程的创建

继承Thread类,并覆盖run()方法

Thread的实现类继承Thread类,并覆盖其run()方法,run()方法中定义线程需要执行的任务,并调用实现类的start()方法创建线程.

调用实现类的run()方法只是单纯的方法调用,并不能新建线程.

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("子线程启动,ID为:" + Thread.currentThread().getId() + ",名字为" + Thread.currentThread().getName());
    }
}

class Test {
    public static void main(String[] args)  {
        // 创建一个线程并开启线程
        MyThread thread = new MyThread();
        thread.start();
        // 多创建几个线程
        new MyThread().start();
        new MyThread().start();
        new MyThread().start();
    }
}

输出如下:

子线程启动,ID为:13名字为Thread-0
子线程启动,ID为:15名字为Thread-2
子线程启动,ID为:16名字为Thread-3
子线程启动,ID为:14名字为Thread-1

实现Runnable接口,并覆盖run()方法

因为Java是单继承的,因此直接继承Thread类常常并不是一个好主意.

我们常常通过实现Runnable接口或Callable接口定义一个任务,并将其传给Thread类构造函数来创建一个线程.其中Runnable类的run()方法没有返回值,而Callable类的call()方法可以有返回值.

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("子线程启动,ID为:" + Thread.currentThread().getId() + ",名字为" + Thread.currentThread().getName());
    }
}

class Test {
    public static void main(String[] args)  {
        // 通过将Runnable对象传入Thread构造函数来创建线程,并开启线程
        Runnable runnable = new MyRunnable();
        Thread thread1 = new Thread(runnable, "线程1");
        thread1.start();
        // 一个Runnable对象可以用来创建多个线程
        new Thread(runnable, "线程2").start();
        new Thread(runnable, "线程3").start();
        new Thread(runnable, "线程4").start();
    }
}

输出如下:

子线程启动,ID为:14,名字为线程2
子线程启动,ID为:13,名字为线程1
子线程启动,ID为:16,名字为线程4
子线程启动,ID为:15,名字为线程3

观察Thread类源代码,会发现Thread类也实现了Runnable接口.

实现Callable接口,并覆盖call()方法

通过该方法创建的任务可以有返回值.但Callable对象只能传给线程池,创建线程的具体方法见后边文章.

要注意CallableRunnable定义的都是任务而不是线程,要将其传入一个线程或线程池后才可以执行.

线程的状态

线程有五个状态:

  1. 新生(new)状态: 用new关键字建立一个线程对象后,该线程对象就处于新生状态,有自己的内存空间.

  2. 就绪(runnable)状态: 调用了start()方法后,线程转为就绪状态,处于就绪状态线程具备了运行条件,但还没分配到CPU,等待系统CPU调度.

  3. 运行(running)状态: 处于运行状态的线程正在执行自己的run()方法中代码.

  4. 阻塞(blocked)状态: 线程暂停执行,让出CPU并将其交给其他线程使用.

  5. 死亡(dead)状态: 当线程完成工作或抛出异常时,线程死亡,不再执行.

    在这里插入图片描述

可以用厕所类比CPU来理解进程的五个状态:

  • 并不是你线程一start()就能办事了,要先排队等待CPU调度,此时处于就绪状态.
  • 当你的时间片用完后,就要出来重新排队,相当于受到CPU调度重新进入就绪状态.
  • 当出现一些原因导致线程运行不下去(等待给送手纸),就进入阻塞状态,直到解决阻塞后有重新排队等待CPU调度,此时处于就绪状态.

线程控制基本方法

判断线程状态的方法

  1. public long getId(): 得到线程的ID

  2. public String getName()public void setName(String name): 得到或设置线程名称

  3. public boolean isAlive(): 判断当前线程是否处于活动状态

    class MyThread extends Thread {
        @Override
        public void run() {
            System.out.println("run:" + this.isAlive());
        }
    }
    
    class Test {
        public static void main(String[] args) throws InterruptedException {
            MyThread myThread = new MyThread();
            System.out.println("begin:" + myThread.isAlive());
            myThread.start();
            System.out.println("end:" + myThread.isAlive());
        }
    }
    

    运行结果如下:

    begin:false
    run:true
    end:true
    

    我们看到程序输出end:true,说明再执行该句输出时,子线程还没运行完毕,实际上多运行几次程序就会发现,这个值是不确定的.

  4. public int getPriority()public void setPriority(int newPriority): 得到或设置线程优先级.

    Java中线程的优先级为1~10之间的整数,一个线程的默认优先级为5.

    Thread.MIN_PRIORITY = 1;
    Thread.MAX_PRIORITY = 10;
    Thread.NORM_PRIORITY = 5;
    

    优先级的高低只是意味着获得调度概率的高低,并不代表调度的绝对顺序.

  5. public static Thread currentThread()返回当前代码段正在被哪个线程调用.

阻塞线程的方法

  1. public void join(),public void join(long millis),public void join(long millis, int nanos): 合并线程,调用某线程的join()方法,会将当前线程与该线程合并,即等待该线程执行结束之后再恢复当前线程的运行.

    join()方法可以看作把并发的线程变为在一个线程内的函数调用

    class MyThread extends Thread {
        @Override
        public void run() {
            for (int i = 1; i <= 5; i++) {
                System.out.println(Thread.currentThread().getName() + ":" + i);
    			sleep(1000);
            }
        }
    }
    
    class Test{
        public static void main(String[] args) throws InterruptedException {
    		// 将子线程thread1 join进主线程,则主线程会等待子线程销毁之后再执行
            Thread thread1 = new MyThread();
            thread1.start();
            thread1.join();
            
    		// 当子线程thread1销毁之后,下边语句才会执行
            // 子线程thread2并没有join进主线程,会和主线程交替执行
            Thread thread2 = new MyThread();
            thread2.start();
            
            for (int i = 1; i <= 5; i++) {
                System.out.println(Thread.currentThread().getName() + ":" + i);
                sleep(1000);
            }
        }
    }
    

    程序输出如下:

    Thread-0:1
    Thread-0:2
    Thread-0:3
    Thread-0:4
    Thread-0:5
    Thread-1:1
    main:1
    main:2
    Thread-1:2
    Thread-1:3
    main:3
    main:4
    Thread-1:4
    main:5
    Thread-1:5
    
  2. public static sleep(long millis),public void sleep(long millis, int nanos): 使线程停止运行一段时间并转入阻塞状态.

    • sleep()方法不会交出锁.
    • 因为sleep()方法使线程进入阻塞状态,因此若调用了sleep()方法后,即使没有其他等待执行的线程,当前线程也不会马上恢复执行.
  3. public static void yield(): 礼让线程,让当前正在执行线程暂停并转入就绪状态.

    • yield()方法不会交出锁.
    • 因为yield()方法使线程进入就绪状态,因此若调用了yield()方法后,没有其他等待执行的线程,当前线程就会马上恢复执行.

线程同步

synchronized关键字

synchronized关键字的意义

  1. synchronized(对象)对括号内的对象加锁,任何线程要执行synchronized代码块中的代码,都必须要先拿到该对象的锁,当代码块执行完毕时,锁就会释放,被其他线程获取

    public class T {
    
        private int count = 10;
        private final Object lock = new Object();	// 锁对象
        
        public void m() {
            synchronized (lock) { // 任何线程要执行下面的代码,都必须先拿到lock锁,锁信息记录在堆内存对象中的,不是在栈引用中
                count--;
                System.out.println(Thread.currentThread().getName() + " count = " + count);
            }
            // 当上述synchronized代码块执行完毕后,锁就会被释放,然后被其他线程获取
        }   
    }
    
  2. 每次使用锁都新建一个锁对象比较麻烦,因此我们可以直接对this对象加锁.

    public class T {
    
        private int count = 10;
        
        public void m() {
            synchronized (this) { // 任何线程要执行下面的代码,必须先拿到this锁
                // synchronized锁定的不是代码块,而是this对象
                count--;
                System.out.println(Thread.currentThread().getName() + " count = " + count);
            }
        }
    }
    
  3. 若整个方法内所有代码都被synchronized修饰,则可以使synchronized关键字修饰整个方法.

    public class T {
    
        private int count = 10;
    
        public synchronized void m() { // 等同于 synchronized (this) { 
            count--;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
        }
        // }
    }	
    
  4. synchronized关键字锁定静态方法,等价于锁定T.class对象

    public class T {
    
        private static int count = 10;
    
        public static synchronized void m() { // 等同于 synchronized (T.class) { 
            count--;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
        }
        
        // 上边m()方法与下边mm()方法等价
        public static void mm() {
            synchronized (T.class) { 
                // 这里不能使用synchronized(this),因为静态方法不需要实例对象即可访问
            	count--;
                System.out.println(Thread.currentThread().getName() + " count = " + count);
            }
        }   
    }
    

synchronized关键字的使用

使用synchronized关键字修饰代码块,保证synchronized代码块内操作的原子性

public class T implements Runnable{

    private int count = 10;
    
    @Override
    public /*synchronized*/ void run() {
        count--;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }

    public static void main(String[] args) {
        T t = new T();	 
        for (int i = 0; i < 5; i++) {
            new Thread(t).start();	// 再这里new的所有线程的锁住的是同一个上边的t对象
        }
    }
}

不加synchronized关键字,程序输出如下: 因为不保证原子性,每个线程在执行自减操作和输出操作之间都可能被其它线程打断.

Thread-0 count = 7
Thread-4 count = 5
Thread-3 count = 6
Thread-2 count = 7
Thread-1 count = 7

加上synchronized关键字,程序输出如下:

Thread-0 count = 9
Thread-4 count = 8
Thread-3 count = 7
Thread-2 count = 6
Thread-1 count = 5

深入理解synchronized关键字

  1. synchronized修饰的代码块中出现异常,线程进行异常处理后会马上释放锁(与ReentrantLock正相反).

    public class T {
    
        int i = 0;
    
        // 同步方法,计数到5抛出异常
        synchronized void m() {
            System.out.println(Thread.currentThread().getName() + " start");
            while (true) {
                i++;
                System.out.println(Thread.currentThread().getName() + ": " + i);
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
    			
                // 计数到5抛出异常
                if (i == 5) {
                    int error = 1 / 0;
                }
            }
        }
    
    
        public static void main(String[] args) {
            T t = new T();
            new Thread(t::m, "线程1").start();
            new Thread(t::m, "线程2").start();
        }
    }
    

    输出如下,我们看到线程1抛出异常后马上释放锁,锁被线程2抢到并开始执行.

    线程1 start
    线程1: 1
    线程1: 2
    线程1: 3
    线程1: 4
    线程1: 5
    线程2 start
    Exception in thread "线程1" 
    java.lang.ArithmeticException: / by zero
    	at T.m(T.java:20)
    	at java.base/java.lang.Thread.run(Thread.java:835)
    线程2: 6
    线程2: 7
    线程2: 8
    线程2: 9
    线程2: 10
    线程2: 11
    
  2. synchronized锁住的是堆中o对象的实例,而不是o对象的引用,因为synchronized是针对堆中o对象的实例上进行计数.

    • 若在程序运行过程中,引用o指向对象的属性发生改变,锁状态不变.
    • 若在程序运行过程中,引用o指向的对象发生改变,则锁状态改变,原本抢到的锁作废,线程会去抢新锁.

    因此实际编程中常将锁对象的引用用final修饰,保证其指向的锁对象不发生改变.(final修饰引用时,该引用所指向的属性可以改变,但该引用不能再指向其他对象)

    public class T {
    
        Object o = new Object();
    
        // 该方法锁住的o对象引用没有被设为final
        void m() {
            synchronized (o) {
                while (true) {
                    System.out.println(Thread.currentThread().getName() + "正在运行");
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    
        public static void main(String[] args) {
            T t = new T();
            new Thread(t::m, "线程1").start();
    
            // 在这里让程序睡一会儿,保证两个线程得到的o对象不同
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    
            Thread thread2 = new Thread(t::m, "线程2");
    
            // 改变锁引用,使得线程2也有机会运行,否则一直都是线程1运行
            t.o = new Object();
            thread2.start();
        }
    }
    

    程序输出如下,看到主线程睡了3秒之后,线程1线程2交替运行,他们各自抢到了不同的锁.

    线程1正在运行
    线程1正在运行
    线程1正在运行
    线程2正在运行
    线程1正在运行
    线程2正在运行
    线程1正在运行
    线程2正在运行
    线程1正在运行
    线程2正在运行
    ...
    
  3. 不要以字符串常量作为锁定对象: 因为字符串常量池的存在,两个不同的字符串引用可能指向同一字符串对象

    public class T {
    
        // 两个字符串常量,作为两同步方法的锁
        String s1 = "Hello";
        String s2 = "Hello";
    
        // 同步m1方法以s1为锁
        void m1() {
            synchronized (s1) {
                while (true) {
                    System.out.println(Thread.currentThread().getName() + ":m1 is running");
                }
            }
        }
    
        // 同步m2方法以s2为锁
        void m2() {
            synchronized (s2) {
                while (true) {
                    System.out.println(Thread.currentThread().getName() + ":m1 is running");
                }
            }
        }
    
        public static void main(String[] args) {
            T t = new T();
    		
            // 输出两个锁的哈希码
            System.out.println(t.s1.hashCode());
            System.out.println(t.s2.hashCode());
    
            new Thread(t::m1, "线程1").start();
            new Thread(t::m2, "线程2").start();
        }
    }
    

    程序执行结果如下,我们发现两个字符串常量指向的是同一对象,且有一个线程永远得不到锁. 若我们的程序与某个库使用了同一个字符串对象作为锁,就会出现难以发现的bug.

    69609650
    69609650
    线程1:m1 is running
    线程1:m1 is running
    线程1:m1 is running
    线程1:m1 is running
    线程1:m1 is running
    线程1:m1 is running 
    
  4. synchronized方法和非synchronized方法是否可以同时执行?

    synchronized方法和非synchronized方法可以同时执行,因为非synchronized方法不需要抢这把锁

    public class T {
    	// 同步方法
        public synchronized void m1() {
            System.out.println(Thread.currentThread().getName() + " m1 start");
            try {
                TimeUnit.SECONDS.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + " m1 end");
        }
    
        // 非同步方法
        public void m2() {
            System.out.println(Thread.currentThread().getName() + " m2 start");
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + " m2 end");
        }
    
        public static void main(String[] args) {
            T t = new T();
            new Thread(t::m1).start();
            new Thread(t::m2).start();
        }
    }
    

    程序输出如下:

    Thread-0 m1 start
    Thread-1 m2 start
    Thread-1 m2 end
    Thread-0 m1 end
    

    我们发现在同步方法m1()执行的同时,非同步方法m2()也在执行.

  5. synchronized是可重入锁,同一线程内同步方法之间可以相互调用

    • 一个同步方法可以调用另外一个同步方法.若一个线程已抢到某对象的锁,再申请时仍然会得到该对象的锁. 因为这是在同一个线程以内,无非就是给锁上的数字加一.

      public class T {
      
          // 一个同步方法
          synchronized void m1() {
              System.out.println("m1 start");
              m2();	// 在同步方法m1()中调用同步方法m2(),不会发生死锁,因为这是在同一线程内的调用
              System.out.println("m1 end");
          }
      	
          // 另一个同步方法
          synchronized void m2() {
              System.out.println("m2 start");
              try {
                  TimeUnit.SECONDS.sleep(1);
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
              System.out.println("m2 end");
          }
      
          public static void main(String[] args) {
              T t = new T();
              new Thread(t::m1).start();
          }
      }
      

      程序输出如下,没有发生死锁,且m1()方法会等待m2()方法结束后继续运行,说明这是函数调用,而非线程并行.

      m1 start 
      m2 start
      m2 end
      m1 end
      
    • 同样的,子类的同步方法可以调用父类的同步方法也不会发生死锁,两个方法锁住的this指向的都是同一个子类对象.

      public class T {
      	// 父类同步方法
          synchronized void m() {
              System.out.println("father method start");
              System.out.println("father method lock:" + this);
              try {
                  TimeUnit.SECONDS.sleep(1);
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
              System.out.println("father method end");
          }
      }
      
      class TT extends T {
          // 子类同步方法
          @Override
          synchronized void m() {
              System.out.println("child method start");
              System.out.println("child method lock:" + this);
              super.m();
              System.out.println("child method end");
          }
      
          public static void main(String[] args) {
              TT tt = new TT();
              new Thread(tt::m).start();
          }
      }
      

      程序输出结果如下,没有发生死锁,且m1()方法会等待m2()方法结束后继续运行,说明这是函数调用,而非线程并行; 另外也可以看到父子的同步方法持有的是同一把锁.

      child method start
      child method lock:thread01.TT@2dd5c6ac
      father method start
      father method lock:thread01.TT@2dd5c6ac
      father method end
      child method end
      

死锁问题

多个进程在执行过程中互相等待对方的资源,导致阻塞

class Task1 implements Runnable {
    @Override
    public void run() {
        try {
            System.out.println("Task1 running");
            while (true) {
                synchronized (DeadLock.lock1) {
                    System.out.println("Task1 get lock1");
                    Thread.sleep(3000);     //获取lock1后此线程睡眠一会,给Lock2足够的时间获得lock2
                    synchronized (DeadLock.lock2) {
                        System.out.println("Task1 get lock2");
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

class Task2 implements Runnable {
    @Override
    public void run() {
        try {
            System.out.println("Task2 running");
            while (true) {
                synchronized (DeadLock.lock2) {
                    System.out.println("Task2 get lock2");
                    Thread.sleep(3000);     //获取lock2后此线程睡眠一会,给Lock1足够的时间获得lock1
                    synchronized (DeadLock.lock1) {
                        System.out.println("Task2 get lock1");
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

class DeadLock {
    // 两个不同的锁对象
    public static Object lock1 = new Object();
    public static Object lock2 = new Object();

    public static void main(String[] args) {
        // 两个线程分别等待对方已经获得的锁
        Thread a = new Thread(new Task1());
        Thread b = new Thread(new Task2());
        a.start();
        b.start();
    }
}

程序输出如下,发现发生了死锁:

Task1 running
Task1 get lock1
Task2 running
Task2 get lock2

等待/通知(wait/notify)机制

wait()notify()方法

wait(),notify()notifyAll()方法是继承自Object的方法,都必须用在synchronized代码块中,其作用如下:

  • public void wait(),public void wait(long timeoutMillis),public void wait(long timeoutMillis, int nanos): 使锁定在此对象上的线程暂停进入阻塞状态,wait()操作会释放锁.
  • public void notify(): 唤醒一个正在wait在此象上的线程.
  • public void notifyAll(): 唤醒正在wait在此对象上的所有进程.

其中wait()方法会释放锁,而notify()方法不会释放锁,而要等到synchronized代码块执行完才释放锁. 因此有时notify()方法唤醒其它线程后要再wait()一下释放锁,这样才有可能保证其它线程马上被唤醒.

生产者/消费者模式

两个线程使用同一容器,使用wait()notify()进行线程间通信的模式为生产者/消费者模式

// 产品类
class Product {
    int id;

    public Product(int id) {
        this.id = id;
    }

    @Override
    public String toString() {
        return "Product{id=" + id + '}';
    }
}

// 同步栈,用于在生产者线程和消费者线程之间通信
class SyncStack {
    int index = 0;  // 栈顶元素上一位的下标

    Product[] products = new Product[4];

    // 向栈中送入产品,synchronized方法保证原子性
    public synchronized void push(Product product) {
        // 栈满了,停止生产
        while (index == products.length) {
            // 为防止wait时发生异常后执行程序剩余部分或栈被其它生产者线程生产满,使用while而非if检测栈状态
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 栈不满,通知消费者线程消费
        // 因为notify()方法不能指定唤醒哪个线程,若只唤醒了另一生产者线程,则会发生死锁,因此我们需要把所有线程都唤醒.
        this.notifyAll();
        products[index] = product;
        index++;
    }

    // 从栈中取出产品,synchronized保证原子性
    public synchronized Product pop() {
        // 栈空了,停止消费
        while (index == 0) {
            // 为防止wait时发生异常后执行程序剩余部分或栈被其它消费者线程消费空,使用while而非if检测栈状态
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 栈不空,通知生产者线程生产
        // 因为notify()方法不能指定唤醒哪个线程,若只唤醒了另一消费者线程,则会发生死锁,因此我们需要把所有线程都唤醒.
        this.notifyAll();
        index--;
        return products[index];
    }
}

// 生产者类
class Producer implements Runnable {
    // 生产者工作的栈
    SyncStack syncStack = null;

    public Producer(SyncStack syncStack) {
        this.syncStack = syncStack;
    }

    @Override
    public void run() {
        // 生产10个产品
        for (int i = 0; i < 10; i++) {
            Product product = new Product(i);
            syncStack.push(product);
            System.out.println("produce" + product);
            try {
                Thread.sleep((int)(Math.random()*200));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

// 消费者类
class Consumer implements Runnable {
    // 消费者消费的栈
    SyncStack syncStack = null;

    public Consumer(SyncStack syncStack) {
        this.syncStack = syncStack;
    }

    @Override
    public void run() {
        // 消费10个产品
        for (int i = 0; i < 10; i++) {
            Product product = syncStack.pop();
            System.out.println("consume" + product);
            try {
                Thread.sleep((int)(Math.random()*1000));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class ProducerConsumer {

    public static void main(String[] args) {
        SyncStack syncStack = new SyncStack();
        // 生产者和消费者都注入了同一个同步栈,线程间的锁都锁在同步栈syncStack上
        Producer producer = new Producer(syncStack);	// 生产者
        Consumer consumer = new Consumer(syncStack);	// 消费者
        // 创建多个生产者线程和消费者线程
        new Thread(producer, "生产者1").start();	// 生产者线程1	
        new Thread(producer, "生产者2").start();	// 生产者线程2
        new Thread(consumer, "消费者1").start();	// 消费者线程1
        new Thread(consumer, "消费者2").start();	// 消费者线程2
    }
}

通过wait/notify机制,我们可以保证生产者和消费者线程不会同时阻塞,消费者每次消费生产者每次生产都会唤醒对方线程.

在写生产者/消费者模式时,要注意下面几个问题:

  • 同步栈SyncStackpush()方法和pop()方法的操作均不具有原子性,因此我们需要通过加以synchronized修饰以保证线程同步.因为生产者和消费者注入的是同一SyncStack对象,因此两线程竞争的是同一把锁.

  • 一进入SyncStack类的push()方法和pop()方法,就要判断当前栈是否满/空,以确保数组不越界.判断栈满/空要使用while语句而非if语句. 有以下两个原因

    1. 在进程被唤醒期间,原本不满/不空的同步栈有可能被其它先被唤醒的生产者/消费者操作过后变满/变空.
    2. 一旦在wait()方法中出现异常,若使用if语句,则就会直接进入catch语句,打印异常并退出if语句,执行后面对数组的操作

    这两种情况下对数组进行操作都是危险的,因此我们要在线程被唤醒后仍然检查一次当前栈状况再操作栈.(根据Effective Java的说法,wait()方法在99.9%的情况下都是跟while语句在一起的).

  • 在唤醒其他线程时,我们使用notifyAll()方法而非notify()方法.这是因为我们无法指定环形的是哪个线程,若只唤醒了一个与本线程同角色(生产者唤醒生产者/消费者唤醒消费者)的线程,则会发生死锁,因此我们使用notifyAll()方法唤醒所有正在等待的线程.(根据Effective Java的说法,要永远使用notifyAll(),而不使用notify()).

volatile关键字

volatile关键字向编译器声明该变量是易变的,每次对volatile关键字的修改会通知给所有相关进程.

  1. 要理解volatile关键字的作用,要先理解Java内存模型JMM

    • JMM中,所有对象以及信息都存放在主内存中(包含堆,栈),而每个线程在CPU中都有自己的独立空间,存储了需要用到的变量的副本.
    • 线程对共享变量的操作,都会先在自己CPU中的工作内存中进行,然后再同步给主内存.若不加volatile关键字修饰,每个线程都有可能从自己CPU中的工作内存读取内存;而加以volatile关键字修饰后,每个线程对该变量进行修改后都会马上通知给所有进程.
    public class T {
    
        /*volatile*/ boolean running = true; // 若无volatile关键字修饰,则变量running难以在每个线程之间共享,对running变量的修改自然不能终止线程
    
    	// 可以通过将running变量设为false来终止m()方法
        void m() {
            System.out.println("m start");
            while (running) { 
                // 死循环
            }
            System.out.println("m end");
        }
    
        public static void main(String[] args) {
            T t = new T();
            new Thread(t::m, "t1").start();
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    		
            // 将running变量设为false,观察线程是否被终止
            t.running = false;
        }
    }
    

    我们发现,若不对running变量加以volatile修饰,则对running变量的修改不能终止子线程,说明在主线程中对running的修改对子线程不可见.

    有趣的是,若在while死循环体中加入一些语句之后,可见性问题可能会消失,这是因为加入语句后,CPU就可能会出现空闲,并同步主内存中的内容到工作内存,但这是不确定的,因此在这种情况下还是尽量要加上volatile

  2. volatile只能保证可见性,但不能保证原子性. volatile不能解决多个线程同时修改一个变量带来的线程安全问题.

    public class T {
    
        volatile int count = 0;
        /*AtomicInteger count = new AtomicInteger(0);*/
    
        /*synchronized*/ void m() {
            for (int i = 0; i < 10000; i++) {
                count++;
                /*count.incrementAndGet();*/
                //incrementAndGet()是原子方法,而count++不是原子方法
            }
        }
    
        public static void main(String[] args) {
            // 创建一个10个线程的容器,其中每个线程都执行m()方法
            T t = new T();
            List<Thread> threads = new ArrayList<>();
            for (int i = 0; i < 10; i++) {
                threads.add(new Thread(t::m, "t-" + i));
            }
    
            // 启动这10个线程并join到主线程,防止主线程先行结束
            for (Thread thread : threads) {
                try {
                    thread.start();
                    thread.join();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
    
            System.out.println(t.count);	// 10个线程,每个线程执行10000次,结果应为100000
        }
    }
    

    运行该程序,我们发现最终变量t.count并非如我们所预计的那样为100000,而是小于100000(当然,若去掉volatile修饰,最终t.count会更小).这说明volatile并不能保证对变量操作的原子性.

    要保证多线程操作同一变量的原子性,有如下两种方法:

    1. 在方法上加synchronized修饰,synchronized既保证可见性,又保证原子性.但synchronized效率最低.
    2. 使用AtomicInteger代替int类型(AtomicXXX类可以用来替代基本数据类型,其支持一些原子操作).

综上所述,volatile保证对被修饰变量的修改对于其他相关线程是可见的,即保证了可见性;但volatile并不能解决多个线程同时修改同一变量带来的线程安全问题,即不能保证原子性. 因此,只有在满足以下两个条件的情况下volatile才能保证解决线程的安全问题.

  1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
  2. 变量不需要与其他状态变量共同参与不变约束这一条不懂

应用题:

题目1: 监控容器内元素个数

  • 题目: 写两个线程,线程1添加10个元素到容器中,线程2实时监控元素的个数,当容器中元素个数达到5时,线程2给出提示并立即结束

  • 解法: 容器选用ArrayList<Object>,调用其add()方法添加元素,调用size()方法得到容器中元素个数.

    • 解法一: 线程2轮询且将容器加volatile修饰

      public class MyContainer {
      
      	// 主要容器,设为volatile保证线程间可见性
          private volatile List<Object> list = new ArrayList<>();	
      
          public void add(Object ele) {
              list.add(ele);
          }
      
          public int size() {
              return list.size();
          }
      
          public static void main(String[] args) {
      
              MyContainer container = new MyContainer();
      
              // 线程1,每隔一秒向容器中添加一个元素
              new Thread(() -> {
                  for (int i = 0; i < 10; i++) {
                      container.add(new Object());
                      System.out.println("add " + i);
                      // 每隔一秒添加一个元素
                      try {
                          TimeUnit.SECONDS.sleep(1);
                      } catch (InterruptedException e) {
                          e.printStackTrace();
                      }
                      
                  }
              }, "线程1").start();
      
              // 线程2,轮询容器内元素个数
              new Thread(() -> {
                  while (true) {
                      if (container.size() == 5) {
                          break;
                      }
                  }
                  System.out.println("监测到容器长度为5,线程2立即退出");
              }, "线程2").start();
          }
      }
      
      

      这种方法存在两个问题:

      1. 不够精确: 若当container.size == 5还未执行break时,被其他线程抢占;或container.add()之后还未打印,就被线程2抢占并判断到container.size == 5并退出了.
      2. 损耗性能: 线程2一直在走while(true)循环,浪费性能
    • 解法二: 使用wait/notify机制,当线程1写入5个元素后通知线程2

      public class MyContainer {
      
          // 主要容器,因为只有线程1对其进行修改和查询操作,所以不用加volatile关键字
          private List<Object> list = new ArrayList<>();
      
          public void add(Object ele) {
              list.add(ele);
          }
      
          public int size() {
              return list.size();
          }
      
          public static void main(String[] args) {
      
              MyContainer container = new MyContainer();
      
              final Object lock = new Object();    // 锁对象
      
              // 线程2先启动并进入wait状态,等待被线程1唤醒
              new Thread(() -> {
                  synchronized (lock) {
                      System.out.println("线程2启动");
                      if (container.size() != 5) {
                          try {
                              lock.wait();
                          } catch (InterruptedException e) {
                              e.printStackTrace();
                          }
                      }
                      System.out.println("监测到容器长度为5,线程2立即退出");
                      // 线程1唤醒线程2后立刻睡眠了,因此线程2退出前要再次唤醒线程1
                      lock.notify();
                  }
              }, "线程2").start();
      
              // 主线程睡2秒钟再创建线程1,确保线程2先得到锁
      		try {
                  TimeUnit.SECONDS.sleep(2);
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
      
              // 线程1,每隔一秒向容器中添加一个元素
              new Thread(() -> {
                  synchronized (lock) {
                      for (int i = 0; i < 10; i++) {
                          container.add(new Object());
                          System.out.println("add " + i);
                          // 当容器中元素个数达到5时,唤醒线程2并退出线程1
                          if (container.size() == 5) {
                              lock.notify();
                              // notify()方法不会释放锁,因此即使通知了线程2,也不能让线程2立刻执行
                              // 所以要先将线程1 wait()住,让其释放锁给线程2,等待线程2退出前再通知唤醒线程1
                              try {
                                  lock.wait();
                              } catch (InterruptedException e) {
                                  e.printStackTrace();
                              }
                          }
                          // 每隔一秒添加一个元素
                          try {
                              TimeUnit.SECONDS.sleep(1);
                          } catch (InterruptedException e) {
                              e.printStackTrace();
                          }
                      }
                  }
              }, "线程1").start();
          }
      }
      

      分析程序中锁移交的顺序,锁再线程1线程2之间进行接力:

      1. 先启动线程2并使主线程睡2秒以确保线程2先抢到锁.
      2. 线程2抢到锁后调用wait(),让其释放锁并阻塞,以确保线程1获得锁.
      3. 线程1抢到锁后开始向容器内添加元素.当线程1添加了5个元素后调用notify()通知线程2并调用wait()释放锁并阻塞,以确保线程2获得锁.
      4. 线程2抢到锁后输出语句并退出,退出之前调用notify()唤醒线程1,因为线程2退出后会释放锁,因此这时不用调用wait()释放锁.

      通过上边分析我们看到: 当不涉及同步,只涉及线程通信的时候,用synchronized+wait/notify机制就显得太重了,实际编程中常用封装层次更深的类库实现线程间通信.

    • 解法三: 使用门闩锁CountDownLatch类锁住线程2,并等待线程1撤去门闩释放线程2

      public class MyContainer {
      
          // 主要容器,因为门闩锁只是一种同步方式,不保证可见性,因此需要用volatile修饰
          private volatile List<Object> list = new ArrayList<>();
      
          public void add(Object ele) {
              list.add(ele);
          }
      
          public int size() {
              return list.size();
          }
      
          public static void main(String[] args) {
      
              MyContainer container = new MyContainer();
      
              // 门闩锁,构造函数中传入门闩数,使用其countDown()方法撤掉一条门闩
              // 当门闩数为0时,门会打开,两个线程都会被执行
              CountDownLatch latch = new CountDownLatch(1);
      
              // 线程2先启动并调用await()让其被门闩锁锁住
              new Thread(() -> {
                  System.out.println("线程2启动");
                  if (container.size() != 5) {
                      try {
                          // 让线程被门闩锁锁住,等待门闩的开放,而不是进入等待队列
                          latch.await();
                      } catch (InterruptedException e) {
                          e.printStackTrace();
                      }
                  }
                  System.out.println("监测到容器长度为5,线程2立即退出");
              }, "线程2").start();
      
              // 主线程睡2秒钟再创建线程1,确保线程2先得到锁
              try {
                  TimeUnit.SECONDS.sleep(1);
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
      
              // 线程1,每隔一秒向容器中添加一个元素
              new Thread(() -> {
                  System.out.println("线程1 启动");
                  for (int i = 0; i < 10; i++) {
                      container.add(new Object());
                      System.out.println("add " + i);
                      // 当容器中元素个数达到5时,撤去一个门闩,打开门闩锁,两个线程都会被执行
                      if (container.size() == 5) {
                          latch.countDown();
                      }
                      try {
                          TimeUnit.SECONDS.sleep(1);
                      } catch (InterruptedException e) {
                          e.printStackTrace();
                      }
                  }
              }, "线程1").start();
          }
      }
      

      门闩锁CountDownLatch在框架中使用的非常广泛,如在Spring框架中,要先实例化所有PropertiesService对象后才能实例化Bean对象.因此我们给初始化Bean对象的线程上一个两道门闩的门闩锁,初始化完毕所有Properties对象后撤去一道门闩,初始化完毕所有Service对象后再撤去一道门闩,两道门闩撤去后,门闩锁打开,创建Bean的线程开始执行.

  • 8
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 15
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 15
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值