5,同步和死锁
5.1,Synchronized,Lock
- 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
- 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
- 修改一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
- 修改一个类,其作用的范围是synchronized后面括号括起来的部分,作用主的对象是这个类的所有对象。
【问题】synchronized的原理?
【答案】
- 在对象头里,有一块数据叫Mark Word。 在64位机器上,Mark Word是8字节(64位,JVM版本不同,有所区别)的,这64位中有2个重要字段:锁标志位(记录自己有没有被某个线程占用)和占用该锁的thread ID(如果这个对象被某个线程占用,它得记录这个线程的thread ID,知道自己是被哪个线程占用了)。
- 每个Java对象都有一个monitor(监视器)。当一个线程在执行synchronized方法或synchronized代码块时,它会尝试获取对象的monitor锁,如果monitor已经被其他线程持有,则该线程会被阻塞,直到monitor被释放。当一个线程完成synchronized方法或synchronized代码块的执行时,它会释放对象的monitor,这样其他线程就可以获取该对象的monitor并执行相应的synchronized方法或synchronized代码块。
【问题】synchronized锁升级?
【答案】
轻量级锁:当一个线程获取锁时,会尝试在对象头中记录锁对象的指针,并将对象头中的锁标记位设置为轻量级锁标记。如果获取锁成功,则直接执行同步代码块,不需要进入内核态。如果其他线程尝试获取锁,则会使用CAS操作尝试获取锁,如果CAS操作成功,则执行同步代码块,否则使用重量级锁。
重量级锁:当多个线程同时竞争锁时,会使用重量级锁,即每个monitor对应一把互斥锁,每次获取锁都需要进入内核态进行操作。重量级锁是最保守的锁形式,适用于高并发、竞争激烈的情况。
自旋锁:在竞争激烈的情况下,线程需要等待其他线程释放锁,此时使用传统的互斥量会引入较大的开销。为了避免这种开销,自旋锁允许线程在一段时间内不断重试获取锁,而不是进入阻塞状态,从而减少了线程切换的开销。
锁消除:在 JIT 编译时,Java 编译器可以对代码进行分析,发现某些锁根本不需要使用,这时就会将锁消除。例如,如果一个锁只会被一个线程访问,那么就可以将该锁消除。
锁粗化:Java 编译器还可以对代码进行分析,发现多个连续的 synchronized 块,将其合并为一个大的 synchronized 块,从而减少锁的粒度,提高效率。
偏向锁:当一个线程获取锁时,会将对象头中的偏向锁标记位设置为1,并将线程ID记录在对象头中。以后该线程获取锁时,只需要检查对象头中的偏向锁标记位和线程ID是否匹配即可,无需进入内核态。如果其他线程尝试获取锁,则会先撤销偏向锁,再使用重量级锁。
(1)同步方法:即有synchronized关键字修饰的方法,由于java的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。需要注意,synchronized关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整类。
public synchronized void test(){ ...... } public static void synchronized test(){ ...... }
等价于:
public void test(){ synchronized(this){ ...... } } public void test(){ synchronized(Main.class){ ...... } }
(2)同步代码块:即有synchronized关键字修饰的语句块,被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。需值得注意的是,同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必要同步整个方法,使用synchronized代码块同步关键代码即可。
synchronized (test){ ...... }
同步方法和同步代码块使用与竞争资源相关的、隐式的同步监听器,并且强制要求加锁和释放锁要出现在一个块结构中,而且当获得了多个锁时,它们必须以相反的顺序释放,且必须在相同的范围内释放所有锁。
同步代码块是更可取的方式,因为它不需要对整个对象加锁,它可以选择任意资源作为同步监视器来加锁。对于同步方式而言,它总是以当前对象或当前类(对于 static 方法)作为同步监视器,这样就不够灵活。如果类中有多个同步代码块,即使它们不相关,只要为它们选择相同的同步监视器,它也会停止它们的执行,并将它们置于等待状态以获得对象上的锁。
【例子】多线程顺序执行
public class Task { public static void main(String[] args) throws Exception { /********* Begin *********/ //在这里创建线程, 开启线程 Object a = new Object(); Object b = new Object(); Object c = new Object(); MyThread ta = new MyThread("A", c, a); MyThread tb = new MyThread("B", a, b); MyThread tc = new MyThread("C", b, c); ta.start(); ta.sleep(100); tb.start(); tb.sleep(100); tc.start(); tc.sleep(100); /********* End *********/ } } class MyThread extends Thread { /********* Begin *********/ private String threadName; private Object prev; private Object self; public MyThread(String name, Object prev, Object self) { this.threadName = name; this.prev = prev; this.self = self; } public void run() { int count = 5; while (count > 0) { synchronized (prev) { synchronized (self) { System.out.println("Java Thread" + this.threadName + this.threadName); count--; self.notify(); } try { prev.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } } System.exit(0); } /********* End *********/ }
(3)同步锁(Lock):从Java 5开始提供了一种功能更强的线程同步机制——通过显式定义同步锁对象来实现同步,在这种机制下,同步锁由Lock对象充当。
Lock是控制多个线程对共享资源进行访问的工具。通常,所提供的了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。
- 某些锁可能允许对共享资源并发访问,如ReadWriteLock(读写锁),Lock、ReadWriteLock是Java 5提供的两个跟根接口,并为Lock提供了ReentranLock(可重入锁)实现类,为ReadWriteLock提供了ReentrantReadWriteLock实现类。
- Java 8新增了新型的StampedLock类,在大多数场景中可以替代传统的ReentrantReadWriteLock。ReentrantReadWriteLock为读写操作提供了三种锁模式:Writing、ReadingOptimistic、Reading。
在实现线程安全的控制中,比较常用的是ReentrantLock(可重入锁),可重入、互斥、实现了Lock接口的锁。使用该Lock对象可以显式地加锁、释放锁。需要注意的是,ReentrantLock还有一个可以创建公平锁的构造方法,但由于能大幅度降低程序运行效率,因此不推荐使用。
Lock和synchronized的区别:
- synchronized是Java关键字,在JVM层面实现加锁和解锁;Lock是一个接口,在代码层面实现加锁 和解锁。
- synchronized可以用在代码块上、方法上;Lock只能写在代码里。
- synchronized在代码执行完或出现异常时自动释放锁;Lock不会自动释放锁,需要在finally中显 示释放锁。
- synchronized会导致线程拿不到锁一直等待;Lock可以设置获取锁失败的超时时间。
- synchronized无法得知是否获取锁成功;Lock则可以通过tryLock得知加锁是否成功。
- synchronized锁可重入、不可中断、非公平;Lock锁可重入、可中断、可公平/不公平,并可以细 分读写锁以提高效率。
两种机制的区别:synchronized原始采用的是CPU悲观锁机制,即线程获得的是独占锁。独占锁意味着其他线程只能依靠阻塞来等待线程释放锁。而在CPU转换线程阻塞时会引起线程上下文切换,当有很多线程竞争锁的时候,会引起CPU频繁的上下文切换导致效率很低;而Lock用的是乐观锁方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁实现的机制就是CAS操作(Compare and Swap)。我们可以进一步研究ReentrantLock的源代码,会发现其中比较重要的获得锁的一个方法是compareAndSetState。这里其实就是调用的CPU提供的特殊指令。
【例子】买票
import java.util.concurrent.locks.ReentrantLock; public class Station extends Thread { private static ReentrantLock lock = new ReentrantLock(); static Integer count = new Integer(20); public void run() { while (true) { if(count>0){ lock.lock(); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("卖出了第" + count-- + "张票"); lock.unlock(); }else { System.out.println("票卖完了"); System.exit(0); } } } public static void main(String[] args) { //实例化站台对象,并为每一个站台取名字 Station station1 = new Station(); Station station2 = new Station(); Station station3 = new Station(); // 让每一个站台对象各自开始工作 station1.start(); station2.start(); station3.start(); } }
使用Lock与使用同步方法有点相似:只是使用Lock时显式使用Lock对象作为同步锁,而使用同步方法时系统隐式使用当前对象作为同步监听器,同样都符合“加锁-修改-释放锁”的操作模式,而且使用Lock对象时每个Lock对象对应一个Account对象,一样可以保证对于同一个Account对象,同一时刻只能有一个线程能进入临界区。
5.2,死锁
同步可以保证资源共享操作的正确性,但是过多的同步也会产生问题。例如:张三需要里斯的画,李四想要张三的书,两者都在等对方交换,这样就出现了死锁的问题。
死锁:两个线程都在等待对方先完成,造成了程序的停滞,一般程序的死锁都是在程序运行时出现的。
class Zhangsan{ public void say(){System.out.println("张三对李四说:你把画给我,我就把书给你。");} public void get(){System.out.println("张三得到画了。");} } class Lisi{ public void say(){System.out.println("李四对张三说:你把书给我,我就把画给你。");} public void get(){System.out.println("李四得到书了。");} } public class HelloWord implements Runnable{ private static Zhangsan zs = new Zhangsan(); private static Lisi ls = new Lisi(); private boolean flag = false; public void run() { if (flag){ synchronized (zs){ zs.say(); try { Thread.sleep(1000); }catch (Exception e){ e.printStackTrace(); } synchronized (ls){ zs.get(); } } }else{ synchronized (ls){ ls.say(); try { Thread.sleep(1000); }catch (Exception e){ e.printStackTrace(); } synchronized (zs){ ls.get(); } } } } public static void main(String[] args) { HelloWord t1 = new HelloWord();HelloWord t2 = new HelloWord(); t1.flag = true;t2.flag = false; Thread a = new Thread(t1);Thread b = new Thread(t2); a.start();b.start(); } } ========================================= 张三对李四说:你把画给我,我就把书给你。 李四对张三说:你把书给我,我就把画给你。 //之后进入死锁
死锁(操作系统):操作系统:处理机调度与死锁(实时调度,死锁条件,死锁解除)_最早截止时间优先算法edf-CSDN博客
解决死锁的方法:
- 避免多次锁定:尽量避免同一个线程对多个同步资源监视器进行锁定。
- 具有相同的加锁顺序:如果多个线程需要对多个同步监视器进行锁定,则应该保证它们以相同的顺序请求加锁。
- 使用定时锁:程序调用Lock对象的tryLock()方法加锁时可指定time和unit参数,当超过指定时间后会自动释放对Lock的锁定。
- 死锁检测:这是一种依靠算法来实现的死锁预防机制,它主要针对那些不可能实现按序加锁,也不能使用定时锁的场景。
5.3,ThreadLocal
Java中的ThreadLocal是一个线程局部变量,它提供了一种在多线程编程中避免线程安全问题的方案。ThreadLocal主要用于在一个线程中存储数据,并且该数据对于其他线程是不可见的,这样就可以保证每个线程访问的数据都是独立的,不会相互干扰。
线程隔离性:ThreadLocal中存储的数据对于每个线程来说都是独立的,其他线程无法访问到该线程中的数据,从而保证了数据的安全性。
数据隔离性:ThreadLocal可以为每个线程提供一个单独的变量副本,这样每个线程可以独立修改自己的变量副本,不会影响其他线程中的变量副本。
减少锁竞争:使用ThreadLocal可以减少线程之间的锁竞争,提高程序的并发性能。
内存泄漏:由于ThreadLocal中存储的数据只与当前线程有关,如果线程结束时没有手动清除对应的数据,可能会导致内存泄漏问题。
【隔离原理】ThreadLocal 实现线程间变量的隔离是通过在每个线程中都创建一个独立的变量副本来实现的。当某个线程访问该变量时,总是获得自己线程中的变量副本,而不同线程获取到的变量副本是互相独立的,彼此之间不会产生影响。
【应用场景】在Spring框架中使用ThreadLocal实现事务管理;在Web框架中使用ThreadLocal实现请求上下文信息的传递;在多线程任务执行中使用ThreadLocal保存任务执行上下文等等。
【数据结构】ThreadLocal的数据结构可以简单理解为一个以Thread为键,以变量值为值的Map结构,其中每个线程都拥有一个自己独立的Map,不同线程之间互不干扰,因此可以实现线程之间的数据隔离。
public class ThreadLocal<T> { private Map<Thread, T> threadLocalMap = Collections.synchronizedMap(new WeakHashMap<Thread, T>()); ... }
在Java中,每个ThreadLocal对象都会生成一个独立的Map对象,该Map对象会在当前线程第一次使用ThreadLocal对象的get()或set()方法时被初始化。之后,每个线程都将拥有自己的独立的Map对象,该Map对象中存储的键值对只有当前线程可以访问。当线程结束时,该线程所对应的Map对象也会随之被回收。因此需要使用WeakReference或者ThreadLocalMap中使用WeakHashMap的方式,保证ThreadLocalMap中的键值对被正确清理,避免内存泄漏。
6,锁&原子变量
6.1,Java中的锁
悲观锁&乐观锁:MySQL:基础,事务,锁,优化_数据库,事务-CSDN博客
共享锁&排他锁:MySQL:基础,事务,锁,优化_数据库,事务-CSDN博客
【读&写锁】与传统锁不同的是读写锁的规则是可以共享读,但只能一个写,总结起来为:读读不互斥、读写互斥、 写写互斥,而一般的独占锁是:读读互斥、读写互斥、写写互斥,而场景中往往读远远大于写,读写锁就是为了这种优化而创建出来的一种机制。注意是读远远大于写,一般情况下独占锁的效率低来源于高并发下对临界区的激烈竞争导致线程上下文切换。因此当并发不是很高的情况下,读写锁由于需要额外维护读锁的状态,可能还不如独占锁的效率 高。因此需要根据实际情况选择使用。
在Java中 ReadWriteLock 的主要实现为 ReentrantReadWriteLock ,其提供了以下特性:
- 公平性选择:支持公平与非公平(默认)的锁获取方式,吞吐量非公平优先于公平。
- 可重入:读线程获取读锁之后可以再次获取读锁,写线程获取写锁之后可以再次获取写锁。
- 可降级:写线程获取写锁之后,其还可以再次获取读锁,然后释放掉写锁,那么此时该线程是读锁 状态,也就是降级操作。
【公平锁&非公平锁】在Java中实现锁的方式有两种:一种是使用Java自带的关键字synchronized(非公平)对相应的类或者方法以及代码块进行加锁,另一种是ReentrantLock(默认非公平但可实现公平的一把锁)。
两者区别在于:公平锁的实现机理在于每次有线程来抢占锁的时候,都会检查一遍有没有等待队列,如果有加入队列等待执行;非公平锁新晋获取锁的进程会有多次机会去抢占锁(不会排队,随机),被加入了等待队列后则跟公平锁没有区别,吞吐量大。
6.2,原子类型&CAS
原子操作是指不会被线程调度机制打断的操作,这种操作一旦开始,就一直运行到结束,中间不会有任何线程上下文切换。Java从JDK 1.5开始提供了java.util.concurrent.atomic包(以下简称Atomic包),这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。Atomic包里的类基本都是使用Unsafe实现的包装类,从而达到了原子性的操作。然后通过将内部的value变量用volatile关键字修饰,从而达到了可见性、防止重排序、原子性。
Atomic包大致可以属于4种类型的原子更新方式:原子更新基本类型(AtomicInteger,AtomicBoolean,AtomicLong)、原子更新数组、原子更新引用、原子更新属性。
compareAndSwap算法(CAS):其实在原子类型中涉及到改变变量数值的操作都会进行CAS算法校验。它是利用Unsafe发送汇编指令达到无锁的状态,其实他是有锁的,只不过他是CPU级别的,不是系统级别的,性能会非常高。它会对比我们的当前值是否和变量值一致,如果一致才会将最新值赋值给变量,否则会快速失败,然后进行循坏尝试。
CAS(Compare and Swap)操作,也叫比较并交换,是一种原子指令。它用于实现多线程间的同步,并解决了多线程访问共享资源时出现的竞态条件问题。
CAS 操作包含三个参数:内存位置 V、预期原值 A 和新值 B。当且仅当 V 的值等于 A 时,才将 V 的值设为新值 B,否则什么都不做。该操作是“读取 - 修改 - 写入”三个步骤的一个原子操作。CAS 操作是在硬件层面实现的,并且是原子操作,因此可以保证线程安全。
在 Java 中,Atomic 类提供了一系列基于 CAS 实现的线程安全的原子操作方法,如 AtomicBoolean、AtomicInteger、AtomicLong 等,可以用来替代 synchronized 关键字进行同步操作。CAS 操作通常结合 volatile 关键字使用,可以保证可见性和有序性。一般来说,在多线程环境下,为了确保一个共享变量的原子性操作,可以先通过 CAS 操作尝试更新变量的值,如果 CAS 操作失败,则需要采用其他方式,如使用锁机制或 Atomic 原子类等。
6.3,AQS
AQS,即AbstractQueuedSynchronizer(抽象队列同步器),是Java中用于实现同步器的一个基础框架。它提供了一个底层的、可扩展的机制,用于构建各种同步工具,如锁、信号量、倒计数器等。AQS是Java并发包中很重要的一部分,它允许多线程之间协调和管理资源的访问。AQS主要通过一个FIFO队列(双向链表)来管理等待线程,并提供了两种方式来自定义同步器的行为:
独占模式:在独占模式下,只有一个线程能够获取同步资源,其他线程必须等待。常见的独占同步器包括ReentrantLock和Semaphore。
共享模式:在共享模式下,多个线程可以同时获取同步资源,通常用于限制访问某个资源的线程数量。常见的共享同步器包括CountDownLatch和CyclicBarrier。
多线程使用AQS的一般流程如下:
定义一个继承自AQS的同步器类,重写相关的方法来定义同步逻辑。例如,在独占模式下,你通常需要实现
tryAcquire
和tryRelease
方法;在共享模式下,你通常需要实现tryAcquireShared
和tryReleaseShared
方法。创建一个线程安全的对象,并将定义好的同步器作为其内部的成员变量。
在需要同步的代码块中,使用同步器来协调线程的访问。通常,你会调用同步器的
acquire
方法来获取资源,在使用完资源后再调用release
方法释放资源。import java.util.concurrent.locks.AbstractQueuedSynchronizer; class MyLock { private final Sync sync = new Sync(); public void lock() { sync.acquire(1); } public void unlock() { sync.release(1); } private static class Sync extends AbstractQueuedSynchronizer { @Override protected boolean tryAcquire(int arg) { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } @Override protected boolean tryRelease(int arg) { if (getState() == 0 || getExclusiveOwnerThread() != Thread.currentThread()) { throw new IllegalMonitorStateException(); } setExclusiveOwnerThread(null); setState(0); return true; } @Override protected boolean isHeldExclusively() { return getState() == 1 && getExclusiveOwnerThread() == Thread.currentThread(); } } }
7,线程通信方式
7.1,wait()、notify()、notifyAll()
如果线程之间采用synchronized来保证线程安全,则可以利用wait()、notify()、notifyAll()来实现线程通信。这三个方法都不是Thread类中所声明的方法,而是Object类中声明的方法。原因是每 个对象都拥有锁,所以让当前线程等待某个对象的锁,当然应该通过这个对象来操作。并且因为当前线程可能会等待多个线程的锁,如果通过线程来操作,就非常复杂了。另外,这三个方法都是本地方法,并且被final修饰,无法被重写。
wait和notify:wait()、notify()、notifyAll()用来实现线程之间的通信,这三个方法都不是Thread类中所声明的方法, 而是Object类中声明的方法。原因是每个对象都拥有锁,所以让当前线程等待某个对象的锁,当然应该通过这个对象来操作。并且因为当前线程可能会等待多个线程的锁,如果通过线程来操作,就非常复杂了。另外,这三个方法都是本地方法,并且被final修饰,无法被重写,并且只有采用synchronized实现线程同步时才能使用这三个方法。
- wait()方法可以让当前线程释放对象锁并进入阻塞状态。
- notify()方法用于唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行。
- notifyAll()方法用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放锁后竞争锁,进 而得到CPU的执行。
每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列。就绪队列存储了已就绪(将要竞争锁) 的线程,阻塞队列存储了被阻塞的线程。当一个阻塞线程被唤醒后,才会进入就绪队列,进而等待CPU 的调度。反之,当一个线程被wait后,就会进入阻塞队列,等待被唤醒。
7.2,await()、signal()、signalAll()
如果线程之间采用Lock来保证线程安全,则可以利用await()、signal()、signalAll()来实现线程通信。这三个方法都是Condition接口中的方法,该接口是在Java 1.5中出现的,它用来替代传统的 wait+notify实现线程间的协作,它的使用依赖于 Lock。相比使用wait+notify,使用Condition的 await+signal这种方式能够更加安全和高效地实现线程间协作。
Condition依赖于Lock接口,生成一个Condition的基本代码是lock.newCondition() 。 必须要注意的是,Condition 的 await()/signal()/signalAll() 使用都必须在lock保护之内,也就是说,必须在 lock.lock()和lock.unlock之间才可以使用。
事实上,await()/signal()/signalAll() 与 wait()/notify()/notifyAll()有着天然的对应关系。即:Conditon中的await()对应Object的wait(), Condition中的signal()对应Object的notify(),Condition中的signalAll()对应Object的notifyAll()。
7.3,volatile
在多线程编程中,不同线程可能会对同一个变量进行读写操作,而缓存和寄存器优化等原因可能导致某个线程对变量的修改在其他线程中不可见,这可能会导致意想不到的错误或不一致性。例如,在多线程场景下,线程A调用set(100),线程B调用get(),在某些场景下,返回值可能不是100。这是因为JVM的规范并没有要求64位的long或者double的写入是原子的。在32位的机器上,一个64位变量的写入可能被拆分成两个32位的写操作来执行。这样一来,读取的线程就可能读到“一半的值”。解决办法也很简单,在long前面加上volatile关键 字。
public class Main { private long a = 0; public void setA(long a) { this.a = a; } public long getA() { return this.a; } }
volatile是一种关键字,用于修饰变量。在多线程中,volatile变量可以用于实现轻量级的线程同步和通信,例如控制线程的停止、线程之间的状态传递等。
【内存可见性】即一个线程对volatile变量的修改对其他线程是可见的,但是它并不能保证操作的原子性。当一个线程修改了volatile变量的值时,其他线程可以立即看到这个值的变化。这是因为,volatile变量的修改会立即被写入到主内存中,并且读取volatile变量时会直接从主内存中读取最新的值,而不是从线程的本地缓存中读取。
【实现原理】在JVM底层volatile是采用“内存屏障”来实现的。当程序对 volatile 修饰的变量进⾏修改时,JIT 编译器生成对应汇编指令时,除了会包含写的动作,在加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障,内存屏障会提供3个功能:
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存 屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 它会强制将对缓存的修改操作立即写入主存;
- 如果是写操作,它会导致其他CPU中对应的缓存行无效。
【volatile的语义】当一个线程修改了
volatile
变量的值时,这个新值会立即被写入主内存,并通知其他线程,其他线程在使用该变量时会重新从主内存中读取最新值,而不是使用自己线程本地的缓存值。为了实现这些语义,Java 规定:
- 当一个线程要使用共享内存中的 volatile 变量时,变量a,它会直接从主内存中读取,而不使用自己本地内存中的副本。
- 当一个线程对一个 volatile 变量进行写时,它会将这个共享变量的值刷新到共享内存中。
【volatile缺点】volatile虽然可以保证可见性,但是它并不能保证原子性。即,两个线程同时对volatile变量进行自增操作,可能会出现竞争条件,导致结果不确定。
public class Demo { private volatile List<Integer> list =new ArrayList<>(); public static void main(String[] args) { Demo2 demo =new Demo2(); new Thread(()->{ for (int i=0;i<10;i++){ demo.list.add(i); System.out.print(Thread.currentThread().getName()); System.out.println(demo.list); } }).start(); new Thread(()->{ for (int i=0;i<10;i++){ demo.list.add(i); System.out.print(Thread.currentThread().getName()); System.out.println(demo.list); } }).start(); } }
7.4,阻塞队列
Java 5提供了一个BlockingQueue接口,虽然BlockingQueue也是Queue的子接口,但它的主要用途并不是作为容器,而是作为线程同步的工具。 BlockingQueue具有一个特征:当生产者线程试图向BlockingQueue中放入元素时,如果该队列已满,则该线程被阻塞;当喜爱欧飞着视图从BlockingQueue中取出元素时,如果该队列已空,则该线程被阻塞。程序的两个线程通过交替向BlockingQueue中放入元素、取出元素,即可很好地控制线程的通信。
BlockingQueue提供如下两个支持阻塞的方法:
- put(E e):尝试把E元素放入BlockingQueue中,如果该队列已满,则阻塞该线程。
- take():尝试从BlockingQueue的头部取出元素,如果该队列已空,则阻塞该线程。
BlockingQueue继承了Queue接口,当然也可使用Queue接口中的方法:
- 在队列尾部插入元素。包括add(E e)、offer(E e)和put(E e)方法,当该队列已满时,这三个方法分别抛出异常、返回false、阻塞队列。
- 在队列头部删除并返回删除的元素。包括:remove()、poll()和take()方法。当该队列已空时,这三个方法分别会抛出异常、返回false、阻塞队列。
- 在队列头部取出但不删除元素。包括element()和peek()方法,当队列已空时,这两个方法分别抛出异常、返回false。
BlockingQueue包含的方法之间的对应关系:
抛出异常 不返回值 阻塞线程 指定超时时间 队尾删除元素 add(e) offer(e) put(e) offer(e,time,unit) 队头删除元素 remove() poll() take() poll(time,unit) 获取、不删除元素 element() peek() 无 无
- ArrayBlockingQueue:基于数组实现的BlockingQueue队列。
- LinkedBlockingQueue:基于链表实现的BlockingQueue队列。
- PriorityBlockingQueue:它并不是标准的阻塞队列。该队列用remove()、poll()、take()等方法取出元素时,并不是取出队列中存在时间最长的元素,而是队列最小的元素。PriorityBlockingQueue判断元素的大小可根据元素的本身大小来判断,也可以根据Comparator进行定义排序。
- SynchronousQueue:同步队列。对该队列的存、取操作必须交替进行。
- DelaQueque:它是一个特殊的BlockingQueue,底层基于PriorityBlockingQueue实现。不过,DelayQueue要求集合元素都实现Delay接口,DelayQueue根据集合元素的getDelay()方法的返回值进行排序。
public class Main { public static void main(String[] args) throws IOException, InterruptedException { BlockingQueue<String> bq = new ArrayBlockingQueue<>(2); bq.put("java");bq.put("java"); bq.put("java"); //阻塞 } }
8,线程池
8.1,线程池介绍
线程池:是一种多线程处理形式,每个请求使用一个线程处理,所有线程预先创建,并发请求数大于线程数时排队等待。一方面系统启动一个新线程的成本是比较高的,因为它涉及与操作系统交互,另外一方面当用户数量很多时,会导致服务器崩溃,线程池可以限制并发用户最大数。在这种情形下,使用线程池可以 很好地提高性能,尤其是当程序中需要创建大量生存期很短暂的线程时,更应该考虑使用线程池。
线程池优点:
避免重复创建线程,减少在创建和销毁线程时所花时间,及系统的整体开销。
避免系统创建大量线程而消耗系统资源。
用户提交的数据能够及时得到处理,响应速度快。
能够更好的监控和管理线程。
线程池缺点:
线程池不支持线程的取消、完成、失败通知等交互性操作。
线程池不支持线程执行的先后次序排序。
不能设置池化线程(线程池内的线程)的Name,会增加代码调试难度。
池化线程通常都是后台线程,优先级为ThreadPriority.Normal。
池化线程阻塞会影响性能(阻塞会使CLR错误地认为它占用了大量CPU。CLR能够检测或补偿(往池中注入更多线程),但是这可能使线程池受到后续超负荷的印象。Task解决了这个问题)。
线程池使用的是全局队列,全局队列中的线程依旧会存在竞争共享资源的情况,从而影响性能(Task解决了这个问题方案是使用本地队列)。
与数据库连接池类似的是,线程池在系统启动时即创建大量空闲的线程,程序将一个Runnable对象或 Callable对象传给线程池,线程池就会启动一个空闲的线程来执行它们的run()或call()方法,当run()或 call()方法执行结束后,该线程并不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个 Runnable对象的run()或call()方法。
从Java 5开始,Java内建支持线程池。Java 5新增了一个Executors工厂类来产生线程池,该工厂类包含 如下几个静态工厂方法来创建线程池。创建出来的线程池,都是通过ThreadPoolExecutor类来实现的:
- newCachedThreadPool():创建一个具有缓存功能的线程池,系统根据需要创建线程,这些线程 将会被缓存在线程池中。
- newFixedThreadPool(int nThreads):创建一个可重用的、具有固定线程数的线程池。
- newSingleThreadExecutor():创建一个只有单线程的线程池,它相当于调用newFixedThread Pool()方法时传入参数为1。
- newScheduledThreadPool(int corePoolSize):创建具有指定线程数的线程池,它可以在指定延 迟后执行线程任务。corePoolSize指池中所保存的线程数,即使线程是空闲的也被保存在线程池内。
- newSingleThreadScheduledExecutor():创建只有一个线程的线程池,它可以在指定延迟后执行 线程任
- ExecutorService newWorkStealingPool(int parallelism):创建持有足够的线程的线程池来支持 给定的并行级别,该方法还会使用多个队列来减少竞争。
- ExecutorService newWorkStealingPool():该方法是前一个方法的简化版本。如果当前机器有4个 CPU,则目标并行级别被设置为4,也就是相当于为前一个方法传入4作为参数。
上面7个方法中前三个方法返回一个ExecutorService对象,该对象代表一个线程池,它可以执行Runnable对象或Callable对象所代表的线程;而中间两个方法返回一个ScheduledExecutorService线程池,它是ExecutorService的子类,它可以在指定延迟后执行线程任务;最后两个方法则是Java 8新增的,这两个方法可以充分利用多CPU并行的能力。这两个方法生成的work stealing池,都相当于后台线程池,如果所有的前台线程都死亡了,work stealing池中的线程会自动死亡。
由于目前计算机硬件的发展日新月异,即使普通用户使用的电脑通常也都是多核CPU,因此Java 8在线程支持上也在增加了利用多CPU并行的能力,这样可以更好地发挥底层硬件的性能。
ExecutorService代表尽快执行线程的线程池(只要线程池中有空闲线程,就立即执行任务),程序只要将一个Runnable对象或Callable对象(代表线程任务)提交给该线程池,该线程池就会尽快执行该任务。ExecutorService里提供了如下三个方法:
- Future<?> submit(Runnable task):将一个Runnable对象提交给指定的线程池,线程池将在有空闲时执行Runnable对象所代表的任务。其中Future对象代表Runnable任务的返回值——但run()方法没有返回值,所以Future对象将在run()方法执行结束后返回null。但可以调用Future的isDone()、isCancelled()方法来获得Runable对象的状态。
- <T>Future<T> submit(Runnable task, T result):将一个Runnable对象 提交给指定的线程池,线程池将在有空闲线程时执行Runnable对象代表的任务。其中result显式指定线程执行结束后的返回值,所以Futrue对象将在run()方法执行结束后返回result。
- <T>Future<T> submit(Callable<T> task):将一个Callable对象提交给指定的线程池,线程池将在有空闲线程时执行Callable对象代表的任务。其中Futrure代表Callable对象的call()方法的返回值。
ScheduledExecutorService代表可在指定延迟后或周期性地执行线程任务的线程池,它提供了如下四个方法:
- ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit):指定callable任务将在delay延迟后执行。
- ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit):指定command任务将在delay延迟后执行。
- ScheduledFuture<?> scheduleAtFixedRate(Runnable commond, long initialDelay, long period, TimeUnit unit):指定commond任务将在delay延迟后执行,而且设定频率重复执行。也就是说,在initialDelay后开始执行,依次在initialDelay+period、initialDelay+2*period、...处重复执行,依次类推。
- ScheduledFuture<?> scheduleWithFixedDelay(Runnable commond, long initialDelay, long delay,TimeUnit unit):创建并执行一个在给定初始的延迟后首次启用的定期操作,随后在每一次执行终止和下一次执行开始之间都存在给定的延迟。如果任务在任一次执行时遇到异常,就会取消后续执行;否则,只能通过程序来显式取消或终止该任务。
8.2,线程池的工作流程
- 判断核心线程池是否已满,没满则创建一个新的工作线程来执行任务。
- 判断任务队列是否已满,没满则将新提交的任务添加在工作队列。
- 判断整个线程池是否已满,没满则创建一个新的工作线程来执行任务,已满则执行饱和(拒绝)策略。
8.3,线程池的状态
线程池一共有五种状态,分别是:
- 运行态:也是线程池的默认状态,当new一个ThreadPoolExecutor实例之后,这个ThreadPoolExecutor的状态就是运行态。运行态能够接受新添加任务,也能够对阻塞队列中的任务进行处理。
- 关闭态:当调用ThreadPoolExecutor实例的showdown()方法之后,这个ThreadPoolExecutor实例就会进入关闭态。关闭态能够对阻塞队列中的任务进行处理,不能够接受新添加的非空任务,但是可以接受新添加的空任务。
- 停止态:当调用ThreadPoolExecutor实例的shutdownNow()方法之后,这个ThreadPoolExecutor实例就会进入停止态。停止态不能接受新添加任务,也不能够对阻塞队列中的任务进行处理,并且会中断正在运行的任务。
- 整理态:当线程池中所有任务已被终止, 这个ThreadPoolExecutor实例就会进入停止态。
- 终止状态:线程池已经关闭并且所有任务都已经完成。在终止状态下,线程池不再执行任何任务,且不能再被重新启动。进入TERMINATED的条件如下:
- 线程池不是RUNNING状态。
- 线程池状态不是TIDYING状态或TERMINATED状态。
- 如果线程池状态是SHUTDOWN并且workerQueue为空。
- workerCount为0。
- 设置TIDYING状态成功。
8.4,线程池的拒绝策略
当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就 会采取任务拒绝策略,通常有以下四种策略:
- AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
- DiscardPolicy:也是丢弃任务,但是不抛出异常。
- DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复该过程)。
- CallerRunsPolicy:(1)如果线程池的核心线程数还没有达到最大线程数(通过线程池构造函数或配置设置的最大线程数),则创建一个新线程来执行当前任务。(2)如果线程池的核心线程数已经达到最大线程数,那么调用线程(也就是提交任务的线程)将会被用来执行该任务。这意味着任务不会被丢弃,而是在提交任务的线程上直接执行,从而避免了任务丢失的风险。
8.5,线程池的队列大小的设置
- CPU密集型任务:量使用较小的线程池,一般为CPU核心数+1。 因为CPU密集型任务使得CPU使用率很高,若开 过多的线程数,会造成CPU过度切换。
- IO密集型任务:可以使用稍大的线程池,一般为2*CPU核心数。 IO密集型任务CPU使用率并不高,因此可以让 CPU在等待IO的时候有其他线程去处理别的任务,充分利用CPU时间。
- 混合型任务:可以将任务分成IO密集型和CPU密集型任务,然后分别用不同的线程池去处理。 只要分完之后两 个任务的执行时间相差不大,那么就会比串行执行来的高效。因为如果划分之后两个任务执行时间 有数据级的差距,那么拆分没有意义。因为先执行完的任务就要等后执行完的任务,最终的时间仍 然取决于后执行完的任务,而且还要加上任务拆分与合并的开销,得不偿失。
8.6,线程池的参数
- corePoolSize(核心工作线程数):当向线程池提交一个任务时,若线程池已创建的线程数小于 corePoolSize,即便此时存在空闲线程,也会通过创建一个新线程来执行该任务,直到已创建的线程数大于或等于corePoolSize时。
- maximumPoolSize(最大线程数):线程池所允许的最大线程个数。当队列满了,且已创建的线 程数小于maximumPoolSize,则线程池会创建新的线程来执行任务。另外,对于无界队列,可忽 略该参数。
- keepAliveTime(多余线程存活时间):当线程池中线程数大于核心线程数时,线程的空闲时间如 果超过线程存活时间,那么这个线程就会被销毁,直到线程池中的线程数小于等于核心线程数。
- workQueue(队列):用于传输和保存等待执行任务的阻塞队列。
- threadFactory(线程创建工厂):用于创建新线程。threadFactory创建的线程也是采用new Thread()方式,threadFactory创建的线程名都具有统一的风格:pool-m-thread-n(m为线程池 的编号,n为线程池内的线程编号)。
- handler(拒绝策略):当线程池和队列都满了,再加入线程会执行此策略。
8.7,使用线程池
使用简单线程池来执行任务的步骤:
- 调用Executors类的静态工厂方法创建一个ExecutorService对象,该对象代表一个线程池。
- 创建Runnable实现类或Callable实现类的实例,作为线程执行任务。
- 调用ExecutorService对象的submit()方法来提交Runable实例或Callable实例。
- 当不想提交任何任务时,调用ExecutorService对象的shutdown()方法来关闭线程池。
public class Main { public static void main(String[] args) throws IOException, InterruptedException { ExecutorService pool = Executors.newFixedThreadPool(6); Runnable target = new Runnable() { public void run() { for (int i = 0; i < 10; i++) { System.out.println(i); } } }; pool.submit(target); pool.submit(target); pool.shutdown(); } }
用完一个线程池后,应该调用该线程池的shutdown()方法,该方法将启动线程池的关闭序列,调用shutdown()方法后的线程池不再接收新任务,但会将以前所有已提交任务执行完成。当线程池中的所有任务都执行完成后,池中的所有线程都会死亡;另外也可以调用线程池的shutdownNow()方法来关闭线程池,该方法视图停止所有正在执行的活动任务,暂停处理正在等待的任务,并返回等待执行的任务列表。