第三篇:并发编程——多线程就是多个“服务员“同时干活

第三篇:并发编程——多线程就是多个“服务员“同时干活 别再背线程状态图了你只需要知道多个服务员抢同一本菜单会出什么乱子写在前面并发编程是Java面试的分水岭。前面那两篇Java基础、集合框架就算你背得再熟也只能算合格。一旦进入并发编程的环节面试官就知道你是会用框架还是真懂原理了。这篇文章的核心就一句话并发编程的本质是多个线程共享同一份资源你得保证不出乱子。别被并发、“并行”、线程安全这些词吓到。你想想现实中的餐厅——多个服务员线程同时接待客人共用同一本菜单共享资源怎么保证菜单不会被撕烂、订单不会被搞混这就叫并发控制。一、进程 vs 线程房东和租客进程资源分配的最小单位。每个进程有自己的内存空间、文件句柄、环境变量。进程切换开销大要换一整块内存地图。线程CPU调度的最小单位。线程不拥有资源它依附在进程里同一个进程里的多个线程共享同一块内存。生活比喻进程 房东手里有一套房子内存空间。线程 租客住在房东的房子里可以轮流用厨房CPU时间片。房东换房进程切换得搬家累死租客换房间线程切换只是换个门锁快得多。面试官追问“Java里start()和run()的区别”run()只是普通方法调用还在当前线程执行没有创建新线程。start()启动一个新线程让JVM去调用run()方法这才是真正的多线程。二、并发三大特性原子性、可见性、有序性所有并发问题归根结底就是这三个特性出了问题。你把这三大特性搞明白了后面所有的锁、volatile、CAS全都能串起来。2.1 原子性一个操作要么全部执行成功要么全部不执行不能被中途打断。经典的count看起来是一行实际上是三步指令从内存读count到CPU寄存器寄存器加1把结果写回内存多线程下如果两个线程同时执行count可能都读到100各自加1变成101再各自写回——结果比预期少了1。这就是原子性被破坏。怎么解决synchronized、Lock、CAS原子类。2.2 可见性一个线程修改了共享变量其他线程能立刻看到修改后的值。CPU有三级缓存L1/L2/L3线程把变量从主内存读到CPU缓存里操作修改完先留在缓存里还没写回主内存。另一个线程从主内存读到的就是旧值。怎么解决volatile强制每次从主内存读写、synchronized解锁时刷新主内存。2.3 有序性程序执行的顺序和代码编写的顺序一致。编译器、CPU为了性能会对指令重排序——在不改变单线程结果的前提下调整执行顺序。单线程没事多线程可能出问题。经典的双重检查锁单例如果不用volatile就可能因为指令重排返回一个还没初始化完成的对象。怎么解决volatile禁止指令重排、synchronized保证有序性。三、volatile轻量级的可见性有序性保证volatile是Java里最轻量的同步机制。它只干两件事保证可见性写volatile变量时强制把CPU缓存的数据刷回主内存读volatile变量时强制从主内存读不走缓存。禁止指令重排在volatile读写前后加内存屏障阻止编译器/CPU重排序。但volatile不保证原子性volatile int count; count照样不是线程安全的因为count不是原子操作。面试官追问“那volatile到底有什么用”用在不依赖当前值的场景或者作为标志位volatilebooleanrunningtrue;// 线程1while(running){doWork();}// 线程2runningfalse;// 让线程1停下来这里线程2改running线程1能立刻看到完美。如果不用volatile线程1可能一直死循环因为缓存没刷新。底层原理volatile的底层靠CPU的缓存一致性协议MESI内存屏障Memory Barrier。说白了就是你声明了volatileCPU就知道这个变量大家共享不要私自缓存改了就通知所有人。四、synchronized重量级锁的锁升级之路synchronized是Java内置锁也叫监视器锁。面试考synchronized现在必问锁升级。4.1 synchronized锁的是什么修饰实例方法 → 锁的是当前对象this修饰静态方法 → 锁的是Class对象修饰代码块 → 锁的是括号里指定的对象4.2 锁升级JDK 1.6JVM层面的优化以前大家都说synchronized是重量级锁效率低。但1.6之后JVM做了大量优化锁的状态会膨胀从轻到重逐步升级① 无锁对象刚创建没有人抢锁。② 偏向锁一个线程来访问同步块JVM觉得就你一个来没必要抢直接把锁标记成偏向这个线程。以后这个线程再来不用任何CAS操作直接进。偏向锁是为了消除单线程下的同步开销。③ 轻量级锁CAS自旋锁第二个线程来竞争了偏向锁撤销升级成轻量级锁。多个线程用CAS自旋抢锁在用户态自旋不涉及操作系统。如果自旋成功了就不用进内核态。自旋默认10次。④ 重量级锁自旋超过了10次或者自旋线程数太多升级成重量级锁。这时候会挂起线程进入操作系统内核态等待唤醒。这才是传统意义上的重量级。为什么要升级其实大多数锁场景下锁被同一个线程反复持有轻量级锁足以应对。真到了多线程激烈竞争的地步再让JVM去跟操作系统申请重量级锁。这是一种乐观的优化策略。4.3 锁消除和锁粗化JVM的智能优化锁消除JVM发现某个锁根本没必要加比如局部变量不会被其他线程访问就直接把锁去掉。锁粗化如果连续加锁解锁JVM会把锁的范围扩大减少加解锁次数。五、synchronized vs ReentrantLock怎么选维度synchronizedReentrantLock实现JVM内置关键字Java API类释放方式自动释放代码块结束或异常必须手动unlock()强烈建议finally里释放锁状态不可中断线程被阻塞只能等可中断lockInterruptibly()超时获取不支持支持tryLock(timeout)公平锁非公平默认非公平但可以传true开启公平条件队列wait()/notify()Condition可以创建多个性能JDK 1.6优化后差距不大同样优秀什么场景必须用ReentrantLock需要尝试加锁tryLock()拿不到就做别的而不是死等。需要可中断锁lockInterruptibly()让等待的线程可以被打断去做其他事。需要多个Condition比如生产者-消费者队列需要notEmpty和notFull两个条件分别唤醒。其他情况优先synchronized——代码更简洁JVM能自动优化锁升级而且不会忘记释放锁。六、AQSAbstractQueuedSynchronizer整个JUC的基石ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock……它们底层全是AQS。6.1 AQS是什么说白了AQS就是一个**“状态标志state 等待队列CLH队列”** 的框架。stateint类型的共享状态。ReentrantLock里state0表示没人持锁state1表示有人持锁1表示重入次数。CLH队列等待获取锁的线程排成一个FIFO双向链表。6.2 获取锁流程以ReentrantLock为例线程尝试用CAS把state从0改成1 → 成功就拿到锁。如果state不是0检查当前线程是不是已经持有锁的线程 → 是就state重入。如果都不是加入CLH队列阻塞LockSupport.park()等前面的线程释放锁后唤醒。AQS两种模式独占模式ExclusiveReentrantLock只有一个线程能拿锁。共享模式SharedCountDownLatch、Semaphore多个线程可以同时拿到许可。七、ThreadLocal每个线程自己存一份数据ThreadLocal不是用来解决共享资源并发问题的——它的作用是让每个线程拥有自己独立的变量副本。7.1 怎么用ThreadLocalStringtlnewThreadLocal();tl.set(张三);// 当前线程存一份Stringnametl.get();// 当前线程取自己的tl.remove();// 用完一定要删每个线程里有个ThreadLocalMap存的是ThreadLocal对象, 值的键值对。所以A线程改自己的值完全不影响B线程。7.2 经典应用场景Spring的RequestContextHolder一个请求从Controller到Service到DAO怎么透传当前用户信息用ThreadLocal。事务管理DataSourceTransactionManager用ThreadLocal保存当前线程的数据库连接确保一个请求里多步操作用的是同一个连接。7.3 最大坑内存泄漏务必removeThreadLocalMap的key是弱引用WeakReferencevalue是强引用。当ThreadLocal对象被GC回收后key变成null但value还在因为Entry本身被Thread的ThreadLocalMap强引用着。线程如果一直活着比如Tomcat线程池里的线程永不销毁这个keynull的Entry就一直存在value无法被回收→ 内存泄漏。如果你用完不调用remove()这个线程下次被复用还可能拿到上一个请求的用户数据 →串号严重安全事故。铁律在finally块里调用threadLocal.remove()用完必删。八、线程池ThreadPoolExecutor线程池是生产环境最常用的并发工具也是面试必问的重中之重。8.1 为什么要用线程池频繁创建和销毁线程开销很大操作系统分配资源、内核态切换。线程池把线程提前创建好用完放回去复用省去创建销毁的开销。8.2 七个参数重申但不重复赘述核心线程数、最大线程数、队列、拒绝策略、存活时间、时间单位、线程工厂。执行顺序再次强调核心线程 → 队列排队 → 最大线程临时工 → 拒绝策略不是一来任务就招临时工核心线程忙不过来了先让任务在队列里排队队列满了才招临时工。8.3 线程数设多少这是面试官的终极追问也是最体现水平的地方。CPU密集型大量计算、加密解密、排序线程数 ≈ CPU核数 1。再多线程切换的开销上下文切换会抵消并行收益。IO密集型大量数据库查询、HTTP调用、读写磁盘线程数可以设很大通常是CPU核数 × (1 平均等待时间 / 平均计算时间)。经验值200-500都常见因为IO等待时CPU闲着可以让别的线程用。公式最佳线程数 CPU核数 × (1 等待时间 / 计算时间)8.4 常用的四种线程池Executors工具类虽然Executors提供了工厂方法但阿里巴巴Java开发手册禁止直接用。newFixedThreadPool核心最大无界队列 → 任务积压可能OOM。newCachedThreadPool核心0最大Integer.MAX队列是SynchronousQueue → 来一个任务创建一个线程可能创建过多线程导致资源耗尽。newSingleThreadExecutor单线程无界队列 → 同样有OOM风险。newScheduledThreadPool定时任务池。强制用new ThreadPoolExecutor()手动指定参数让开发者明确知道自己在配置什么。九、死锁四个条件凑齐了两个线程互相等对方释放锁谁都不放就死锁了。死锁的四个必要条件互斥资源只能一个线程用。占有并等待拿着一个资源不放还等另一个。不可抢占已经占有的资源不能被强行抢走。循环等待A等BB等A形成环路。破一个就行。最常见的是破除循环等待——给锁排序所有线程按固定顺序加锁。排查死锁jstack或jconsole可以dump线程栈看到Found one Java-level deadlock直接定位。十、面试官真正想听什么场景1“你们系统遇到过并发问题吗”不要只背概念。说真实案例“我们有个库存扣减接口原先没用锁高并发下出现了超卖。后来改用Redis的decrement原子操作数据库乐观锁版本号解决了。因为Redis原子操作是单线程的天然线程安全。”场景2“你项目里线程池怎么设的”不要说我用的默认要说“我们有个批量导出任务是IO密集型大量查数据库我设了核心线程20、最大200、队列1000拒绝策略用的CallerRunsPolicy让调用线程自己执行作为降级。因为导出任务不能丢也不能把服务器打崩。”场景3“ThreadLocal遇到过内存泄漏吗”加分回答“遇到过。我们之前在一个拦截器里用ThreadLocal存用户信息忘记了在finally里remove结果Tomcat线程池复用线程时偶尔会拿到上个请求的用户导致串号。后来统一封装了工具类在拦截器的afterCompletion里强制remove。”小结核心概念大白话面试考点原子性操作不能被打断count为什么不安全可见性改完别人能看到volatile和synchronized区别有序性代码按顺序执行指令重排、双重检查锁volatile轻量级同步不保证原子性、内存屏障synchronized内置锁锁升级过程偏向→轻量→重量ReentrantLockAPI锁tryLock、Condition、公平锁AQSstate等待队列JUC锁的底层框架ThreadLocal线程本地变量内存泄漏、remove必调线程池复用线程7参数、执行顺序、大小设置记住一句话并发编程考的不是你背了多少锁而是你知不知道多个人一起干活会出什么乱子以及怎么防。下一篇我们讲JVM——Java程序到底是怎么在内存里活着的垃圾回收真的有那么玄乎吗我们下一期见。