本文共 15291 字,大约阅读时间需要 50 分钟。
红色是面试点?
如果多个线程访问一个对象的状态变量没有做同步措施,程序就可能出现错误。可以弥补的措施有: 1、状态变量不在线程之间共享 2、将状态修改为不可变的变量 3、访问该状态变量的时候使用同步(似乎和问题条件冲突) |
当设计线程安全的类时,良好的面向对象技术、不可修改性一级明细的不变性规范都能起到作用 |
面向对象的抽象和封装会降低性能 |
使用线程安全的类可以避免去纠结线程安全问题 |
线程安全的定义:当多个线程访问某个类时,不管是什么调度方式或者线程交替执行,在主调代码中不需要额外的同步或协同,这个类都能表现出正确的行为。这个类就是线程安全的。 |
无状态的类一定是线程安全的。 |
count++到底做了什么? count++在指令层面做了 读取-修改-写入 三个步骤,这三个步骤是一个操作序列,在多线程中可能因为多个线程读取了初始值,A线程修改了值,但是B和C线程仍是在初始值的基础上做修改,读取修改写入是竞态条件的一种典型情况。 |
竞态条件(raceCondition) 竞态条件我觉得翻译成竞态现象更贴切一些。由于执行时序不同导致错误结果的现象。最常见的竞态现象是 检查——执行(CHECK-THEN-ACT),检查是观察值的情况,执行是采取动作,由于检查的时候失去了时间片,其他线程对数据做了修改,此时在拿到时间片去执行,基于的数据就是错误的。即基于错误的观察结果而执行动作。 读取修改写入和检查执行是常见的竞态现象,读取修改写入常用于修改已有的值,在赋值过程中读取的值发生了变化导致原来基于的数据是错误的,而检查执行就是基于错误的观察结果去执行代码。 |
竞态现象的实例:延迟初始化 public class LazyInitRace{ private ExpensiveObj instance = null; public ExpensiveObj getInstance(){ if(instance==null){ instance = new ExpensiveObj(); } return instance; } } 如果A B线程在该类没有初始化的情况下同时去执行获取实例方法的时候,两个线程都走到了判空,下一个指令都是初始化,初始化了两个实例,最终就会导致有一个实例被覆盖掉,如果里面包含的是用户信息就会导致用户数据丢失的情况。 |
原子性 原子性是并发程序正确执行的三大必要特性之一,其他两个是可见性和有序性。 如果某个操作有多个线程要执行,那么在B执行之前,A的操作要么全部执行完,要么还没开始,B不能再A的操作过程中对数据进行操作。那么这个操作就被称为是原子的。 count++本身操作不是原子的,但是通过synchronized修饰符可以让操作变成原子的,我认为 操作原子化 这个修饰比较贴切。 原子操作是原子的,但是原子操作的组合不是原子的。 如Vector.contains()方法和Vector.add()方法都是原子的,但是二者组合起来先判断是否包含,再添加对象,就是一个典型的检查——执行的竞态现象。 并发中的原子性和事务中的原子性相似,事务中的原子性是一系列数据库操作要么都完成要么还没开始,不能在操作过程中有其他数据修改。 |
synchronized关键字 具体可以看博文 锁所包含的代码一定是原子操作,一个线程在执行synchronized代码块的时候其他线程是一定不能进入这个代码块的。 synchronized锁被称为内置锁或者监视器锁。 内置锁是一个互斥锁,同一时间点只能有一个线程持有这个锁,除了持有锁的线程外其他线程不可以执行内置锁包含的代码。获取内置锁的唯一途径是执行锁中的代码。 不当的使用可能会导致性能问题。 |
可重入锁 可重入锁是指一个线程获取到锁后,在释放前仍可以再获取同样的锁。可重入锁的粒度是线程而不是调用,pthread的粒度是调用。 锁的一种实现方法是为锁设置两个参数,当前占用个数0或1,当前线程,如果当前占用为0就是已释放,任何线程都可以获取这个锁,如果当前占用是1,那么就比对来请求这个锁的是否是记录的当前线程,如果是,则可以执行。 可重入锁的设计如果某个线程获取了某个对象的锁,那么在他释放之前他一定可以无限次的获取当前这个锁。 |
不可重入锁 不可重入锁与可重入锁相对,假设某对象obj的两个方法methodA和methodB是加锁的,methodA中调用了methodB。 那么在某线程执行methodA时,他将无法执行methodB,methodA方法也无法执行完成,造成死锁。 因为不可重入锁记录的是调用,他只记录了当前锁是否被占用,当线程调用methodA时,这个对象的锁被设置为了占用,此时再去执行methodB时,锁判断当前的状态是占用的,所以其他线程都无法进来,导致methodB无法执行,methodA也就无法执行完毕。 最终造成死锁。 其他线程也无法进入A,因为这个对象的锁是占用的。 |
servlet不是线程安全的,他的service方法没有内置锁,但是servlet设计的初衷就是可以多个线程执行 |
原子变量和原子操作不建议一起使用,容易造成混乱和性能问题,如要将一个servlet设置成线程安全的,可以选择使用线程安全的类,也可以选择在servlet中需要同步的地方加上锁。 |
执行时间长的代码不要持有锁,如网络IO,jdbc等 |
指令重排 指令重排是指在单线程环境中,多个指令顺序调换并不影响最终的结果。 如int a = 1; int b = 2; 这两条执行先后顺序暂时看来不影响最终结果。先初始化a和先初始化b没有影响。 但是如果放入到多线程环境中,如下代码,如果对number=42和ready=true进行重排序,先执行ready=true,且这个时候还没有对number进行赋值,则代码会进入到打印number,打印0。 (实际上在8G i5的环境下,好不容易复现了还不知道是不是复现的对的) public class MyThread{ private static boolean ready = false; private static int number = 0; private static class ReaderThread extends Thread{ @Override public void run() { while(!ready) { Thread.yield(); } System.out.println(number);; } } public static void main(String[] args) throws InterruptedException { new ReaderThread().start(); number = 42; ready = true; } } |
64位数据的操作和最低安全条件 java中如果是对64位基本数据(long和double)进行操作的话,如果没有加volatile修饰,在多线程环境中可能会造成读取数据错乱的现象。 所谓最低安全条件是指:即使在多线程环境中读取数据出错了,也是之前数据存放的有意义的曾经的值,这个是多线程中的最低安全条件。 64位数据操作不满足这个条件,因为在java中64位数据的操作是可以分解为两个对32数据操作的结果之和的,很可能读到的值是两个线程操作的两部分值的和。所以除非用volatile或者同步锁,否则64位的共享数据不满足最低安全条件。 |
volatile变量 volatile修饰的变量在编译和运行都不会被重排序 volatile修饰的变量不会别缓存在寄存器中,因此必须从主存中读取,修改也是直接修改主存的值 操作volatile变量不会加锁,所以相对synchronized是一个轻量级的锁 volatile控制的是可见性,不能控制原子性,volatile修饰的count的count++操作仍然是非线程安全的,即某一时刻读取到的count一定是主存中的,但是读取修改写入操作中的后两步依然是基于第一步读取到的数据的加锁机制既可以保证原子性和可见性,但是volatile只能保证可见性,通常用作某个操作完成、发生或者中断的标志 |
发布 逸出 发布一个对象是指使这个对象可以在作用域以外的地方使用。如将对象申明成一个public static对象,或者在一个非私有方法中返回这个对象的引用,或者将对象的引用交给其他类的方法。发布某个对象的某个部分也是发布这个对象。如一个List<Obj> list ;如果修改其中任一个obj,那么也是发布了这个对象,因为你修改了obj就是修改了这个list 逸出就是发布不该发布的对象。 不要在构造函数的过程中发布对象的this引用,如启动一个线程,线程中引用了对象的this引用,此时对象还没有构造完成。可以在构造函数中定义线程,但是不要start。可以定义工厂方法,将构造方法私有。 总结: 对象分配在堆中,变量里保存了对对象的引用。如果某个局部变量的对象引用通过方法传递或者返回给其他地方,则是将引用交了出去,其他地方获取了这个引用。就是发布。 不该发布的时候发布了,就是逸出。 |
线程封闭 当访问共享的可变数据时,通常情况下,是使用同步。但是如果将数据设置为不共享数据,只在某个线程内访问,就不需要同步。(又是一个和定义冲突的解决方案,书上这么写的,是不是翻译问题,呕) 这种将数据设置为仅单线程内可访问的计数被称为线程封闭。将数据封闭在某个线程里,这个数据仅对这个线程可见。线程封闭的对象本身可以不是线程安全的。如虽然我的数据读取修改写入不是同步的,但是我这个数据同一时刻只有某个线程可见,那么这个读取修改写入操作也具备线程安全性。 典型的有jdbc连接池,连接池在每次需要使用时去获取一个连接,用完之后再返回,在返回之前其他线程看不到这个链接,所以这个连接对象是线程封闭的。(大多数请求如servlet执行过程中是同步的(启动过程不是,是服务器多线程启动多个线程用同一个 servlet去执行的,但是servlet内部是同步的),即不会在执行过程中去启动另一个线程,并将连接发布到这个线程中,所以connection可以认为是线程封闭的) 局部变量和ThreadLocal类也是线程封闭的 Ad-hoc线程封闭 维护线程封闭完全由程序来实现。 栈封闭 栈封闭中只能通过局部变量才能访问对象(局部变量是局部变量表中的变量,包括方法参数和方法中声明的对象,this在局部变量表中也有,而且是第一个,但是应该不是这里说的局部变量)。 如果局部变量是一个基本类型,那么这个变量一定是栈封闭的,因为在java中基本类型只能传值(对象名义是按引用传值,但是实际上也是按值传递,因为对象本身存的就是引用地址的值,而传值就是将他本身包含的引用地址的值传过去),所以基本变量的局部变量一定是栈封闭的。 ThreadLocal封闭 ThradLocal确保每个线程中获取到的值和其他线程是相互隔离的。 |
不变性 不可变的对象一定是线程安全的。 如果对象创建后状态就不可更改,所有域都是final类型,对象创建过程中没有逸出,那么这个对象就是不可变的。 除非一个对象的某个域需要公开,否则就应该是private的,同理,除非某个对象是需要改变你的,否则也应该是final的,这是一个编程习惯。 |
监视器模式 监视器模式是把对象的所有可变状态都封装起来,并且用状态对象自己的锁去保护(状态对象的,不是监视器对象的,如果状态是基本数据类型,那么就要使用监视器对象)。 public class PrivateLock{ private LockObj lockObj; // lockObj是私有对象,但是这个对象被方法封装,且有内置锁保护 public void func(){ synchronized(lockObj){ // 这里锁的lokObj ... } } } |
线程安全性委托 线程安全性委托是指一个类是由多个状态变量组成的,这个类将自己的安全性委托给自己所包含的状态变量。 但是组件安全不意味着类就安全。有时候可能要给其中多个变量的操作再套上一个安全层。 如某个监听器类,包含鼠标监听器和键盘监听器,由于鼠标监听和键盘监听没有直接关联,所以监听器类可以将线程安全性委托给这两个组件。 举一个多个状态变量之间有关联关系的例子。 一个类有两个状态变量,分别是minSize,一个是maxSize,前者必须小于后者。此时即便两个变量是线程安全的,但是组合起来使用,就可能产生线程安全问题,如在设置最小值时还没有读取到最大值所以通过了,但是还没有设值,在设置最大值时没有读取到最小值,也走到了设值的前一步,所以最终导致两者关联关系失效。此时就需要加锁来完成线程安全。 |
一种特别的构造函数 设有个类,他的功能是包装某个map,构造函数的参数是外部的map对象,即 public Obj(Map map){}; 这里对map做的操作不是赋值,而是将map做一个深拷贝,拷贝的对象赋值到这个对象obj的成员域中。这样外部对map的修改不会影响到obj 另外方法unmodifiablemap可以生成不可修改的map对象 |
一个加错锁导致的线程安全问题 public class Obj{ public List<String> list = Collections.synchronizedList(new ArrayList<E>); public synchronized void modifyList(E x){ boolean absent = !list.contains(x); if(absent){ list.add(x); } return absent; } } 这个锁虽然在这个方法上加了锁,并且list也是线程安全的对象,但是锁是加载obj对象上的,假设此时判断是否包含是不包含,执行完后释放list锁,后面其他地方获取到list锁对list做修改,当前obj的锁并不能阻止他,这里的list是public的,外界很容易获取,其他形式的get方法和这种情况同理 实际上应该加如下锁 public class Obj{ public List<String> list = Collections.synchronizedList(new ArrayList<E>);; public void modifyList(){ synchronized(list){ boolean absent = !list.contains(x); if(absent){ list.add(x); } return absent; } } }通过组合添加原子操作 public class ImprovedList{ private List<String> list = Collections.synchronizedList(new ArrayList<E>);;public ImprovedList(List list){ this.list = list}; public synchronized void clear(){ ..... } public synchronized void modifyList(){ boolean absent = !list.contains(x); if(absent){ list.add(x); } return absent; } // 其他list方法一样 } 由于这里的list并没有对外开放获取,所有的操作必须通过improvedList去操作,所以只要保证了improvedList是线程安全的,那么底层的list也一定是线程安全的 |
客户端加锁 如某个类Obj的get和set方法是原子的,但是组合起来不是原子的,通过在调用端(客户端)给get和set一起加锁,保证操作原子性就是客户端加锁 |
同步容器类和并发容器类 二者都是线程安全的,但是有时候需要客户端加锁。如vector就是同步容器类。 同步容器的坏处是同步锁太多,严重影响性能。如concurrentHashMap就是并发容器,用于替代同步的散列map,CopyOnWriteList用于代替同步的list,并发容器可以极大提高伸缩性并降低风险。 ConcurrentLinkedQueue先进先出队列,PriorityQueue非并发队列但是可以设置优先级 BlockingQueue扩展了queue,增加了可阻塞的插入和获取操作,如果队列为空,那么获取元素的操作一直阻塞,直到有可用的数据,如果元素满了,那么插入的操作一直阻塞,直到有位置。 队列分为有限长度的和无限长度的。无限长度的队列永远不会插入阻塞。 |
阻塞队列和生产者消费者模式 生产者消费者模式是两端代码,A端负责生产任务,B端负责处理任务,通过可阻塞队列可以简化代码。如A端生产了任务,防止到阻塞队列中,B端通过轮询去获取,获取到之后处理。阻塞队列如果是优先队列的话可以限制任务处理个数,让待处理任务不要太多导致处理不完。 阻塞队列提供了一个offer方法,offer方法也可以插入数据,但是如果插入失败的话会返回false,这样可以根据返回结果去执行处理策略,如将代办项写入磁盘,或者抑制生产者线程。queue有add remove方法是非阻塞的 put和take是可以阻塞的 offer有返回状态 // 生产者类public class Produce implements Runnable{ public void run() { try { while(true) { if(MainFunc.queue.offer(String.valueOf(MainFunc.queue.size()+1))) { Thread.sleep(1000); System.out.println(new Date().toString()+"新的任务已添加,现在还有"+MainFunc.queue.size()+"个任务"); }else { Thread.sleep(1000); System.out.println(new Date().toString()+"插入失败,现在还有"+MainFunc.queue.size()+"个任务"); } } } catch (InterruptedException e) { e.printStackTrace(); } }}//消费者类public class Consume implements Runnable{ public void run() { try { while(true) { String task = MainFunc.queue.take(); Thread.sleep(2000); System.out.println(new Date().toString()+"__"+task+"任务已处理,还剩"+MainFunc.queue.size()); } } catch (InterruptedException e) { e.printStackTrace(); } }}// 主类public class MainFunc { public static BlockingQueue |
阻塞和中断 阻塞是等待外部其他动作完成,是否能继续进行下去由外部事件决定,如等待IO结果,等待锁可用或者等待某项计算完成。 阻塞是一类方法,这些需要等待外部事件来决定是否继续执行下去的方法叫阻塞方法,而中断方法是阻塞方法独有的。 中断方法可以选择在阻塞方法等待结果时去做一些操作,这个操作需要开发人员自己去定义。而阻塞方法在接收到中断通知后就会抛出中断异常,开发人员可以决定后面执行的操作。 所以诸如sleep的方法还有阻塞队列的put方法和take方法都会抛出阻塞异常。await()方法也是阻塞方法 下面是一个阻塞方法被中断的例子。 这是中断的常规使用方式,另外线程中还有判断是否中断的方法,如isInterrupt(),和interrupted()方法,通常中断异常的处理方式就是直接抛出给上一层,或者简单处理后再继续抛出
|
双端队列与工作密取 Deque是一个双端队列,可以在列头和列尾进行插入和移除。 阻塞队列适用于生产者消费者模式。 双端队列则适用于工作密取。工作密取中每个消费者都有各自的双端队列,如果一个消费者完成了自己的消费队列,那么他可以去另一个双端队列的列尾去取任务执行,而原来的消费者是从队头取数据。 这样可以保证所有的消费者线程都在运行,且当自己队列执行完后执行其他消费者队列时减少大量竞争。 |
同步工具类 同步工具类是可以根据自身的状态来协调线程的控制流的类。 如阻塞队列,可以根据put take是否阻塞来协调是否继续执行下去。 闭锁:根据是否到达结束状态(如计数器变为0)来控制当前线程是否继续执行下去 其他还有信号量和栅栏 |
闭锁 latch 闭锁是这样一种锁,他必须等待某些事务执行完毕才会执行。 常见的应用有如下: 确保某个计算所需要的资源都执行完毕 确保依赖的服务都启动完毕 如下代码,假设查询结果依赖于其他两个查询 public class Demo { private int a,b; public int getA() { return a; } public void setA(int a) { this.a = a; } public int getB() { return b; } public void setB(int b) { this.b = b; } public static void main(String[] args) { System.out.println("这是一个需要到数据库里查询两条大数据量然后整合的程序"); CountDownLatch countDownLatch = new CountDownLatch(2); Demo demo = new Demo(); Thread thread1 = new Thread(()->{ try { System.out.println(new Date().toString()+ "————线程1去数据库里查询A数据"); Thread.sleep(3000); System.out.println(new Date().toString()+ "————过了三秒后线程1的数据查询完毕,a赋值3"); demo.setA(3); } catch (InterruptedException e) { e.printStackTrace(); } countDownLatch.countDown(); }); Thread thread2 = new Thread(()->{ try { System.out.println(new Date().toString()+"————线程2去数据库里查询B数据"); Thread.sleep(5000); System.out.println(new Date().toString()+"————过了五秒线程2的数据查询完毕,a赋值5"); demo.setB(5); } catch (InterruptedException e) { e.printStackTrace(); } countDownLatch.countDown(); }); Thread thread3 = new Thread(()->{ System.out.println(new Date().toString()+"————线程3开始,然后等待线程1和线程2的结果执行完"); try { countDownLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(new Date().toString()+"等待完毕,获取A和B的和"+(demo.getA()+demo.getB())); }); thread1.start(); thread2.start(); thread3.start(); } }执行结果如下 这是一个需要到数据库里查询两条大数据量然后整合的程序 Wed Sep 12 10:25:55 CST 2018————线程3开始,然后等待线程1和线程2的结果执行完 Wed Sep 12 10:25:55 CST 2018————线程1去数据库里查询A数据 Wed Sep 12 10:25:55 CST 2018————线程2去数据库里查询B数据 Wed Sep 12 10:25:58 CST 2018————过了三秒后线程1的数据查询完毕,a赋值3 Wed Sep 12 10:26:00 CST 2018————过了五秒线程2的数据查询完毕,a赋值5 Wed Sep 12 10:26:00 CST 2018等待完毕,获取A和B的和8另一种代码一起开始并且等待线程是主线程的demo public class Demo2 { public static void main(String[] args) throws InterruptedException { CountDownLatch startGate = new CountDownLatch(1); CountDownLatch endGate = new CountDownLatch(3); for(int i =0; i<3; i++) { Thread thread = new Thread(()->{ try { startGate.await(); System.out.println(Thread.currentThread().getName()+"任务开始"); Thread.sleep(3000);// 执行对应任务 } catch (InterruptedException e) { e.printStackTrace(); } finally { System.out.println(Thread.currentThread().getName()+"任务结束"); endGate.countDown(); } }); thread.start(); } startGate.countDown(); // 这样可以保证多个线程是一起开始的 endGate.await(); // 主线程等待其他线程执行完成 System.out.println(333); } }Thread-0任务开始 Thread-1任务开始 Thread-2任务开始 Thread-1任务结束 Thread-0任务结束 Thread-2任务结束 333 |
FutureTask 也可以用作闭锁,实现了Callable接口,相对于runable接口他可以有返回结果,而runable是没有返回结果的,另外future还有一些状态。当新建一个future时需要将需要执行的代码传入进去,相当于新建一个线程时传入run方法。而获取结果通过get方法去获取。 代码如下 public class FutureTaskDemo { private final FutureTask<String> future = new FutureTask<>( new Callable<String>() { public String call() throws Exception { Thread.sleep(3000); return "333"; }; } ); // 这里新建了一个future类,这个类中放了call函数,相当于新建线程时传入的run方法 private final Thread thread = new Thread(future); // 将future作为参数传给一个线程,future既继承了callable也继承了runable public void start() { thread.start(); } public String get() throws InterruptedException, ExecutionException { return future.get(); } public static void main(String[] args) throws InterruptedException, ExecutionException { System.out.println(new Date()); FutureTaskDemo demo = new FutureTaskDemo(); demo.start(); // 启动线程 System.out.println(demo.get()); // 获取结果 System.out.println(demo.get()); // 获取结果 一旦计算完了后面就不需要计算了 System.out.println(demo.get()); // 获取结果 一个future中存储一个计算结果 System.out.println(demo.get()); // 获取结果 计算完成之前获取方法是阻塞的 System.out.println(demo.get()); // 获取结果 完成之后就用于存储值 System.out.println(demo.get()); // 获取结果 但是代价是不是太大了毕竟一个对象挺大的 System.out.println(new Date()); } } 结果如下 Wed Sep 12 15:30:58 CST 2018 333 333 333 333 333 333 Wed Sep 12 15:31:01 CST 2018 |
信号量 Semaphore 基于之前学习的两个闭锁类,我发现核心方法都是阻塞方法 如CountDownLatch,核心方法是await方法,是阻塞方法,countDown当然也是核心方法 如上面的FutureTask类,核心方法是get方法,也是阻塞方法,用于获取结果,另外一个核心就是设置内部callable方法 信号量里的方法也有很多,但是在我看来核心方法就是acquire()(获取许可,阻塞方法)和release()(释放许可,但是非阻塞方法) 我觉得信号量的作用就是限定同一时间最多有多少个任务可以并发执行 代码如下: public class SemaphoreDemo { private Semaphore semaphore = new Semaphore(3); public void acquire() throws InterruptedException { semaphore.acquire(); } public void release() { semaphore.release(); } public int availablePermits() { return semaphore.availablePermits(); } public static void main(String[] args) throws InterruptedException { SemaphoreDemo demo = new SemaphoreDemo(); for(;;) { Thread thread = new Thread(()->{ try { synchronized (demo) { demo.acquire(); System.out.println(Thread.currentThread().getName()+"获取到了许可,现在还有"+demo.availablePermits()+"个许可"); } Thread.sleep(5000); synchronized (demo) { demo.release(); System.out.println(Thread.currentThread().getName()+"释放了许可,现在还有"+demo.availablePermits()+"个许可"); } } catch (InterruptedException e) { e.printStackTrace(); } }); thread.start(); Thread.sleep(2000); } } } 结果如下:由于时间差,所以会经常在获取请求那里阻塞,到后期通常只剩余1个许可 Thread-0获取到了许可,现在还有2个许可 Thread-1获取到了许可,现在还有1个许可 Thread-2获取到了许可,现在还有0个许可 Thread-0释放了许可,现在还有1个许可 Thread-3获取到了许可,现在还有0个许可 Thread-1释放了许可,现在还有1个许可 Thread-4获取到了许可,现在还有0个许可 Thread-2释放了许可,现在还有1个许可 Thread-5获取到了许可,现在还有0个许可 Thread-3释放了许可,现在还有1个许可 Thread-6获取到了许可,现在还有0个许可 Thread-4释放了许可,现在还有1个许可 |
阻塞方法原理实现 这边要看留存的疑虑太多 所有的阻塞似乎都和await有关 所有await都和无限循环等待释放条件有关 |