Java学习之==> 多线程
一、创建线程的三种方式
第一种
public class App { public static void main(String[] args) { Thread thread = new Thread(() -> { while (true) { System.out.println("testThread"); } }); thread.start(); } }
第二种
public class App { public static void main(String[] args) { Thread thread = new Thread(new Runnable() { @Override public void run() { while (true) { System.out.println("testThread"); } } }); thread.start(); } }
第二种和第一种本质上是同一种方法,只不过第一种方法用的是 lambda表达式的写法。
第三种
public class App extends Thread{ public static void main(String[] args) { App app = new App(); app.run(); } @Override public void run() { System.out.println("testThread"); } }
二、synchronized关键字与锁
为了解决多个线程修改同一数据而发生数据覆盖或丢失的问题,Java提供了synchronized关键字来加保护伞,以保证数据的安全。synchronized关键字对共享数据的保护有两种方式,一种是用于修饰代码块,一种是用于修饰方法。
1、修饰代码块
修饰代码块是把线程体内执行的方法中会涉及到修改共享数据时的操作,通过{}封装起来,然后用synchronized关键字修饰这个代码块。我们先来看一下修饰方法和代码块的情况下的线程安全问题:
public class App { private int i; public static void main(String[] args) { App app = new App(); new Thread(() -> { app.produce(); }).start(); new Thread(() -> { app.consume(); }).start(); } public void produce() { while (i < 5) { i++; System.out.println("produce = " + i); } } public void consume() { while (i > 0) { i--; System.out.println("consume = " + i); } } } // 输出结果 produce = 1 consume = 0 produce = 1 produce = 1 consume = 0 consume = 1 consume = 0 produce = 2 produce = 1 produce = 2 produce = 3 produce = 4 produce = 5
从输出结果来看,显然两个线程对变量 i 的操作出现了混乱,如果我们使用 synchronized 关键字来修饰,是不是问题就能解决呢?我们来看看下面这段代码:
public class App { private int i; public static void main(String[] args) { App app = new App(); new Thread(() -> { app.produce(); }).start(); new Thread(() -> { app.consume(); }).start(); } public void produce() { synchronized (this){ while (i < 5) { i++; System.out.println("produce = " + i); } } } public void consume() { synchronized (this){ while (i > 0) { i--; System.out.println("consume = " + i); } } } } // 输出结果 produce = 1 produce = 2 produce = 3 produce = 4 produce = 5 consume = 4 consume = 3 consume = 2 consume = 1 consume = 0
从测试结果来看,增加 synchronized 关键字后,数据没有错乱了,下面来分析一下为什么没有加 synchronized 关键字时会出现线程安全问题,先来看下面这个图:
我们知道,类的信息是存储在运行时数据区的方法区,而类属性(全局变量)自然也是存储在运行时数据区的方法区,属于线程共享的区域。每一个线程运行时都有一个线程栈,线程栈保存了线程运行时候变量值信息。当线程访问某一个对象时候值的时候,首先通过对象的引用找到对应在堆内存的变量的值,然后把堆内存变量的具体值load到线程本地内存中,建立一个变量副本,之后线程就不再和对象在堆内存变量值有任何关系,而是直接修改副本变量的值,在修改完之后的某一个时刻(线程退出之前),自动把线程变量副本的值回写到对象在堆中变量。这样在堆中的对象的值就产生变化了。
当我们使用两个线程同时执行 produce 和 consume 方法时,会在栈中复制一个 i 的副本并存储在栈中,两个线程在各自的栈中对 i 进行了操作并且更改了 i 的值(此时两个线程栈中 i 的值是不一样的),这时两个线程分别把栈中 i 的值刷回给主内存中,这时就造成了 i 的值的混乱。
加上 synchronized 关键字后,对 i 的操作再主内存中进行,两个线程竞争锁资源,竞争到锁资源的线程对 i 进行操作,没竞争到锁资源的线程进入 BLOCKED 状态等待其他线程释放锁后再重新竞争,这样 i 的值就不会错乱。值得注意的是:synchronized 关键字的锁对象应该是同一个对象,否则,会有问题。
public class App { private static int i; public static void main(String[] args) { new Thread(()->{ new App().produce(); }).start(); new Thread(()->{ new App().consume(); }).start(); } public void produce() { synchronized (this){ while (i < 5) { i++; System.out.println("produce = " + i); } } } public void consume() { synchronized (this){ while (i > 0) { i--; System.out.println("consume = " + i); } } } } // 输出结果 produce = 1 produce = 2 consume = 1 consume = 1 consume = 0 produce = 2 produce = 1 produce = 2 produce = 3 produce = 4 produce = 5
从以上代码上看,虽然 synchronized 关键字的锁对象都是 this,但执行 produce() 和 consume() 方法使用的是两个不同的对象,即它的锁对象不是同一个对象,虽然 i 定义为了静态变量,操作的是同一个 i ,但是又出现了线程安全问题,这时,我们需要把 synchronized 关键字的锁对象改为 App.class,如下:
public class App { private static int i; public static void main(String[] args) { new Thread(()->{ new App().produce(); }).start(); new Thread(()->{ new App().consume(); }).start(); } public void produce() { synchronized (App.class){ while (i < 5) { i++; System.out.println("produce = " + i); } } } public void consume() { synchronized (App.class){ while (i > 0) { i--; System.out.println("consume = " + i); } } } } // 输出结果 produce = 1 produce = 2 produce = 3 produce = 4 produce = 5 consume = 4 consume = 3 consume = 2 consume = 1 consume = 0
2、修饰方法
synchronized 关键字修饰方法时,放在返回值的前面,如果该方法是静态方法,则锁对象是当前类的Class对象,如果不是静态方法,锁对象是 this
public class App { private static int i; public static void main(String[] args) { new Thread(()->{ new App().produce(); }).start(); new Thread(()->{ new App().consume(); }).start(); } public static synchronized void produce() { while (i < 5) { i++; System.out.println("produce = " + i); } } public static synchronized void consume() { while (i > 0) { i--; System.out.println("consume = " + i); } } } // 输出结果 produce = 1 produce = 2 produce = 3 produce = 4 produce = 5 consume = 4 consume = 3 consume = 2 consume = 1 consume = 0
下面我们来做一个小练习:多线程的生产者和消费者模式,两个线程循环打印 1 和 0
public class App { private int i; public static void main(String[] args) { App app = new App(); new Thread(app::produce).start(); new Thread(app::consume).start(); } public synchronized void produce() { while (true) { if (i < 5) { i++; System.out.println("produce = " + i); this.notify(); } try { this.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } } public synchronized void consume() { while (true) { if (i > 0) { i--; System.out.println("consume = " + i); this.notify(); } try { this.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } } }
public class App { private static int i; public static void main(String[] args) { new Thread(()->{ new App().produce(); }).start(); new Thread(()->{ new App().consume(); }).start(); } public static synchronized void produce() { while (true) { if (i < 5) { i++; System.out.println("produce = " + i); App.class.notify(); } try { App.class.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } } public static synchronized void consume() { while (true) { if (i > 0) { i--; System.out.println("consume = " + i); App.class.notify(); } try { App.class.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } } }
三、Lock接口
在Lock接口出现之前,Java程序是靠 synchronized 关键字实现锁功能的。JDK1.5之后并发包中新增了Lock接口以及相关实现类来实现锁功能。虽然synchronized方法和语句的范围机制使得使用监视器锁更容易编程,并且有助于避免涉及锁的许多常见编程错误,但是有时我们需要以更灵活的方式处理锁。例如,用于遍历并发访问的数据结构的一些算法需要使用“手动”或“链锁定”:您获取节点A的锁定,然后获取节点B,然后释放A并获取C,然后释放B并获得D等。在这种场景中synchronized关键字就不那么容易实现了,使用Lock接口容易很多。
synchronized 关键字与 Lock 的区别:
- Lock不是Java语言内置的,synchronized是Java语言的关键字,因此是内置特性。Lock是一个类,通过这个类可以实现同步访问;
- Lock和synchronized有一点非常大的不同,采用synchronized不需要用户去手动释放锁,当synchronized方法或者synchronized代码块执行完之后,系统会自动让线程释放对锁的占用;而Lock则必须要用户去手动释放锁,如果没有主动释放锁,就有可能导致出现死锁现象。
synchronized 的局限性与 Lock 的优点
如果一个代码块被synchronized关键字修饰,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待直至占有锁的线程释放锁。事实上,占有锁的线程释放锁一般会是以下三种情况之一:
- 占有锁的线程执行完了该代码块,然后释放对锁的占有;
- 占有锁线程执行发生异常,此时JVM会让线程自动释放锁;
- 占有锁线程进入 WAITING 状态从而释放锁,例如在该线程中调用wait()方法等。
那我们试图考虑以下三种情况:
- 在使用synchronized关键字的情形下,假如占有锁的线程由于要等待IO或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,那么其他线程就只能一直等待,别无他法。这会极大影响程序执行效率。因此,就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间 (解决方案:tryLock(long time, TimeUnit unit)) 或者 能够响应中断 (解决方案:lockInterruptibly())),这种情况可以通过 Lock 解决;
- 我们知道,当多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作也会发生冲突现象,但是读操作和读操作不会发生冲突现象。但是如果采用synchronized关键字实现同步的话,就会导致一个问题,即当多个线程都只是进行读操作时,也只有一个线程在可以进行读操作,其他线程只能等待锁的释放而无法进行读操作。因此,需要一种机制来使得当多个线程都只是进行读操作时,线程之间不会发生冲突。同样地,Lock也可以解决这种情况 (解决方案:ReentrantReadWriteLock) ;
- 我们可以通过Lock得知线程有没有成功获取到锁 (解决方案:ReentrantLock) ,但这个是synchronized无法办到的。
上面提到的三种情形,我们都可以通过Lock来解决,但 synchronized 关键字却无能为力。事实上,Lock 是 java.util.concurrent.locks包 下的接口,Lock 实现提供了比 synchronized 关键字 更广泛的锁操作,它能以更优雅的方式处理线程同步问题。也就是说,Lock提供了比synchronized更多的功能。
Lock接口的简单使用
public class App { private int i; private Lock locker = new ReentrantLock(); public static void main(String[] args) { App app = new App(); new Thread(app::produce).start(); new Thread(app::consume).start(); } public void produce() { locker.lock(); try { while (i < 5) { i++; System.out.println("produce = " + i); } } finally { locker.unlock(); } } public void consume() { locker.lock(); try { while (i > 0) { i--; System.out.println("consume = " + i); } } finally { locker.unlock(); } } }
为了防止 while 代码块中的代码执行报错而导致的Lock锁不释放,我们应该把 while 代码块放入 try...finally 中,finally中放入 lock.unlock(),无论 while 代码块执行是否报错都会释放锁。
使用Condition实现等待/通知机制
synchronized关键字与wait()和notify/notifyAll()方法相结合可以实现等待/通知机制,ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition() 方法。Condition是JDK1.5之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。
在使用notify/notifyAll()方法进行通知时,被通知的线程是由JVM选择的,使用ReentrantLock类结合Condition实例可以实现“选择性通知”,这个功能非常重要,而且是Condition接口默认提供的。而synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。
public class App { private int i; private Lock locker = new ReentrantLock(); private Condition produceCondition = locker.newCondition(); private Condition consumeCondition = locker.newCondition(); public static void main(String[] args) { App app = new App(); new Thread(app::produce).start(); new Thread(app::consume).start(); } public void produce() { locker.lock(); try { while (true) { while (i < 5) { i++; System.out.println("produce = " + i); consumeCondition.signalAll(); } try { produceCondition.await(); } catch (InterruptedException e) { e.printStackTrace(); } } } finally { locker.unlock(); } } public void consume() { locker.lock(); try { while (true) { while (i > 0) { i--; System.out.println("consume = " + i); produceCondition.signalAll(); } try { consumeCondition.await(); } catch (InterruptedException e) { e.printStackTrace(); } } } finally { locker.unlock(); } } }
四、volatile
关键字
用 volatile
修饰的变量,线程在每次使用变量的时候,都会读取变量修改后的最的值。一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile
修饰之后,那么就具备了两层语义:
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的;
- 禁止进行指令重排;
可见性
先来看下下面这段代码:
public class App { static int i = 0; static boolean flag = false; public static void main(String[] args) throws Exception { new Thread(() -> { while (!flag) { i++; } }).start(); Thread.sleep(2000); flag = true; System.out.println("stop:" + i); } }
上面这段代码是通过 flag 来控制线程退出 while 循环的例子。这段代码貌似没什么问题,但是执行后却发现程序没有退出,子线程仍然在运行,也就是说主线程设置的 app.flag = true 没有起作用。
首先,子线程在运行的时候会把变量 flag 与 i 从“主内存” 拷贝到线程栈内存,然后 子线程开始执行 while 循环,while (!flag) 进行判断的 flag 是在子线程工作内存当中获取,而不是从 “主内存”中获取,所以子线程拿到的 flag 的值一直都是 false,while 循环会一直执行下去。主线程同样将变量 flag 与 i 从“主内存” 拷贝到线程栈内存,修改 flag=true. 然后再将新值回到主内存。
虽然主线程修改了 flag 的值为 true,但子线程使用的一直是线程栈当中 flag = false,所以 while 循环就一直在执行,没有停止。这也是JVM为了提高性能而做的优化。如何能让子线程每次判断 flag 的时候都强制它去主内存中取值呢?这里就要用到 volatile
关键字了,如下:
public class App { static int i = 0; static volatile boolean flag = false; public static void main(String[] args) throws Exception { new Thread(() -> { while (!flag) { i++; } }).start(); Thread.sleep(2000); flag = true; System.out.println("stop:" + i); } }
原子性
所谓原子性,即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。从这点看,有点像数据库的事务。
那么 volatile
有原子性吗?我们先看一段代码:
public class App { private volatile int inc = 0; private void increase() { inc++; } public static void main(String[] args) throws InterruptedException { App app = new App(); for (int i = 0; i < 10; i++) { new Thread(() -> { for (int j = 0; j < 1000; j++) { app.increase(); } }).start(); } // 保证前面的线程都执行完 Thread.sleep(2 * 1000); System.out.println(app.inc); } } // 运行结果 9893 9972 9935 10000
我们使用10个线程,每个线程自增1000次,本来我们预期的最终结果是10000,但是我们执行了四次,其中三次的结果都小于10000。可能大家会有疑问:volatile
变量 inc
不是保证了共享变量的可见性了吗,每次读取到的难道不是最新的值?
是的,没错,但是线程每次将值写回主内存的时候并不能保证主内存中的值没有被其他线程修改过。再说详细点:线程 1
在主存中获取了 inc
的最新值(inc
=1),线程 2
也在主存中获取了 inc
的最新值(inc
=1,注意这时候线程 1
并未对变量 inc
进行修改,所以 inc
的值还是 1
),然后线程 2
将 inc
自增后写回主存,这时候主存中 inc=2
,到这里还没有问题,然后线程 1
又对 inc
进行了自增写回了主存,这时候主存中 inc=2
,也就是对i做了2次自增操作,结果i的结果只自增了1,问题就出来了这里。
为什么会有这个问题呢?因为 volatile
关键字 保证了变量读操作的原子性和写操作的原子性,而变量的自增过程需要对变量进行读和写两个过程,而这两个过程连在一起就不是原子性操作了。
所以说
volatile
关键字不能保证原子性,想要解决这个问题,只能给 increase()方法加锁,在多线程执行的情况下只能有一个线程执行increase()方法。
使用 volatile关键字的场景
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
- 对变量的写操作不依赖于当前值;
- 该变量没有包含在具有其他变量的不变式中;
实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用 volatile 关键字的程序在并发时能够正确执行。
下面列举几个Java中使用volatile的几个场景:
状态标记量
volatile boolean flag = false; while(!flag){ doSomething(); } public void setFlag() { flag = true; }
double check
class Singleton{ private volatile static Singleton instance = null; private Singleton() { } public static Singleton getInstance() { if(instance==null) { synchronized (Singleton.class) { if(instance==null) instance = new Singleton(); } } return instance; } }
五、线程池
Java中频繁的创建和销毁线程是非常消耗资源的,为了减少资源的开销,提高系统性能,Java 提供了线程池。Java 中创建线程池主要有以下两种方式:
- Executors
- ThreadPoolExecutor
Executors
public class App { public static void main(String[] args) { ExecutorService executors = Executors.newFixedThreadPool(5); Runnable runnable = () -> { System.out.println("testThread..."); }; for (int i = 0; i < 5; i++) { executors.submit(runnable); } executors.shutdown(); } }
我们不建议使用这种方式创建线程池,先来看下以下这段代码:
public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()); } public LinkedBlockingQueue() { this(Integer.MAX_VALUE); } @Native public static final int MAX_VALUE = 0x7fffffff;
使用 Executors.newFixedThreadPool() 来创建线程池,调用的是 ThreadPoolExecutor() 类的构造方法,其中有个参数是 new LinkedBlockingQueue<Runnable>() 队列,允许的请求队列长度为 Integer.MAX_VALUE,如果短时间接收的请求太多的话,队列中可能会堆积太多请求,最终导致内存溢出。
ThreadPoolExecutor
正确的创建线程池的方式如下:
public class App { public static void main(String[] args) { ThreadFactory namedThreadFactory = new ThreadFactoryBuilder() .setNameFormat("demo-pool-%d").build(); ExecutorService executors = new ThreadPoolExecutor( 5, 200, 10, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1024), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy()); Runnable runnable = () -> { System.out.println("testThread..."); }; for (int i = 0; i < 5; i++) { executors.submit(runnable); } executors.shutdown(); } }
指定线程的名称、核心线程数、最大线程数,阻塞队列长度以及拒绝策略,拒绝策略的意思是队列中的请求超过设定的值时,我们该如何操作,上面这段代码中的 new ThreadPoolExecutor.AbortPolicy() 代表拒绝,如果对于超出阻塞队列长度的请求不想拒绝的话,可以把请求存入其他介质,比如 redis 当中。我们先来看一下AbortPolicy的实现:
public static class AbortPolicy implements RejectedExecutionHandler { /** * Creates an {@code AbortPolicy}. */ public AbortPolicy() { } /** * Always throws RejectedExecutionException. * * @param r the runnable task requested to be executed * @param e the executor attempting to execute this task * @throws RejectedExecutionException always */ public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { throw new RejectedExecutionException("Task " + r.toString() + " rejected from " + e.toString()); } }
AbortPolicy 类实现自 RejectedExecutionHandler 接口,如果我们想要用其他的处理方式来处理超出队列长度的请求,可以自己来写一个策略实现 RejectedExecutionHandler接口的 rejectedExecution()方法。
六、JUC
JUC就是 java.util .concurrent 工具包的简称,这是一个处理线程的工具包,这里我们挑选几个工具来介绍一下。
倒数计数器
使用多线程来执行任务,如果需要等到所有线程执行完再来统计结果,这时可以用 countDownLatch.countDown()来倒数,countDownLatch.countDown() 设置的数字一般和线程数一致,如果不是所有线程都执行完了,则会使用 countDownLatch.await()进行等待,也可以设置超时时间结束等待,下面我们举个例子来看一下:
public class App{ static ThreadFactory threadFactory = new ThreadFactoryBuilder() .setNameFormat("demo-pool-%d") .setDaemon(true) .build(); public static void main(String[] args) throws InterruptedException, ExecutionException { // 创建线程池 ExecutorService executorService = new ThreadPoolExecutor( 5, 200, 5, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1024), threadFactory, new ThreadPoolExecutor.AbortPolicy()); // 设置倒数计数器 CountDownLatch countDownLatch = new CountDownLatch(11); int sum = 0; for (int i = 0; i < 10; i++) { Future<Integer> submit = executorService.submit(()->{ try { return Integer.valueOf(RandomStringUtils.randomNumeric(3)); }catch (Exception e){ e.printStackTrace(); return -1; }finally { countDownLatch.countDown(); } }); sum += submit.get(); } // 如果不想一直等下去,则可设置超时时间 countDownLatch.await(2, TimeUnit.SECONDS); // 持续等待 // countDownLatch.await(); System.out.println("sum = " + sum); executorService.shutdown(); } }
上面这个例子,每个线程都会返回一个随机数,目的是将这10个线程返回的随机数相加,所以就必须等到这10个线程都返回结果后才能进行计算,所以用 countDownLatch.countDown(11)进行倒数,又因为它设置的数字为 11,比线程数还多一个,所以在10个线程都返回结果后不会立即打印结果,而是等待 countDownLatch.await(2, TimeUnit.SECONDS) 中设置的超时时间再打印结果。
流控器
流控器的作用是对线程的使用进行限流,比如:线程池的最大线程数为 10,程序中要启动 10个线程来执行任务,如果我不想线程池中的线程全部被用完,想要保留几个线程作为其他任务使用,这时我们可以对线程进行限流,使用Semaphore,如下:
public class App{ static ThreadFactory threadFactory = new ThreadFactoryBuilder() .setNameFormat("demo-pool-%d") .setDaemon(true) .build(); public static void main(String[] args) throws InterruptedException { // 创建线程池 ExecutorService executorService = new ThreadPoolExecutor( 5, 200, 5, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1024), threadFactory, new ThreadPoolExecutor.AbortPolicy()); // 设置倒数计数器 CountDownLatch countDownLatch = new CountDownLatch(11); // 设置流量控制器/信号量 Semaphore semaphore = new Semaphore(2); for (int i = 0; i < 10; i++) { executorService.submit(() -> { try { semaphore.acquire(); System.out.println("Hello,world..."); Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } finally { countDownLatch.countDown(); semaphore.release(); } }); } countDownLatch.await(); executorService.shutdown(); } }
值得注意的是:semaphore.acquire() 应该放在入口,程序最后都要使用 semaphore.release() 将线程进行释放,否则其他线程也进不来,为了保证每次线程执行完都会被释放,semaphore.release() 应该放在 finally 代码块中,无论 try 代码块中的代码是否报错,线程都会被释放。