Appearance
并发编程类
本章内容都由网上整合,以及个人思考,仅用于学习分享,如有侵权部分或者思考错误地方,欢迎大家及时提出,本人一定及时纠正。
进程和线程的区别
进程:是系统运行程序的基本单位。在 Java 中,当我们启动
main
函数的时候就是启动了一个JVM
的进程,而main
函数所在的线程就是这个进程中的一个线程,也称为主线程线程:线程是进程中的一个执行单元。一个进程可以包含多个线程,这些线程共享进程的内存空间和系统资源。线程是操作系统调度的最小单位,负责执行进程中的任务,但是线程的并发执行容易导致竞态条件(多个线程同时访问和修改共享数据)、死锁(两个或多个线程互相等待对方释放资源)等问题。
补充:
在
JVM
中,多个线程共享进程的堆和方法区(JDK1.8之后方法区被替换成元空间)资源,但是每个线程都有属于自己的程序计数器、虚拟机栈和本地方法栈线程的实现方式:
- 用户级线程:由用户程序库管理,操作系统内核感知不到,切换速度快,但如果一个线程阻塞,整个进程都会阻塞。
- 内核级线程:由操作系统内核管理,能够利用多处理器优势,但切换开销较大。
- 轻量级进程(LWP):介于用户级线程和内核级线程之间,由内核管理,但与普通进程相比,LWP共享大部分资源,减少了开销
JVM
中通常使用操作系统的本地线程(内核级线程)
Java 创建线程有哪几种方式
继承
Thread
类实际开发中,更常用的还是通过实现
Runnable
接口。因为 Java 不支持多继承javapublic class MyThread extends Thread{ private String threadName; public MyThread(String threadName){ this.threadName = threadName; } @Override public void run() { for (int i = 1; i <= 10; i++) { System.out.println(this.threadName + " " + i); try { Thread.sleep(1000); } catch (InterruptedException e) { System.out.println(threadName + "被中断"); } } System.out.println("执行完毕"); } public static void main(String[] args) { MyThread t1 = new MyThread("t1"); MyThread t2 = new MyThread("t2"); t1.start(); t2.start(); } }
实现
Runnable
接口javapublic class MyRunnable implements Runnable { private String threadName; public MyRunnable(String name) { this.threadName = name; } @Override public void run() { for (int i = 1; i <= 5; i++) { System.out.println(threadName + " - 计数: " + i); try { // 使线程休眠一段时间,模拟实际任务的耗时操作 Thread.sleep(1000); // 休眠 1 秒 } catch (InterruptedException e) { System.out.println(threadName + " 被中断。"); } } System.out.println(threadName + " 执行完毕。"); } public static void main(String[] args) { // 创建 MyRunnable 实例 MyRunnable myRunnable = new MyRunnable("线程1"); // 创建 Thread 对象,将 MyRunnable 实例传入 Thread thread = new Thread(myRunnable); thread.start(); // 主线程继续执行 for (int i = 1; i <= 5; i++) { System.out.println("主线程 - 计数: " + i); try { // 使主线程休眠一段时间 Thread.sleep(500); // 休眠 0.5 秒 } catch (InterruptedException e) { System.out.println("主线程 被中断。"); } } System.out.println("主线程 执行完毕。"); } }
使用
Callable
和Future
javaimport java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; public class CallableFutureExample { public static void main(String[] args) { // 创建一个线程池,指定池中有一个线程 // 使用到了Executor ExecutorService executorService = Executors.newFixedThreadPool(1); // 创建一个 Callable 任务 Callable<Integer> task = new Callable<Integer>() { @Override public Integer call() throws Exception { int sum = 0; // 计算 1 到 5 的累加和 for (int i = 1; i <= 5; i++) { sum += i; System.out.println("计算中: " + i); try { // 模拟耗时操作,线程休眠 1 秒 Thread.sleep(1000); } catch (InterruptedException e) { System.out.println("线程被中断"); } } return sum; // 返回计算结果 } }; // 提交任务并获取 Future 对象 Future<Integer> future = executorService.submit(task); try { // 获取任务执行结果,get() 方法会阻塞直到任务完成 Integer result = future.get(); System.out.println("任务执行结果: " + result); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } finally { // 关闭线程池 executorService.shutdown(); } } }
使用
Executor
请看上一个的实例代码
总结:
- Executor: 负责管理线程池和任务调度,提供任务执行的环境。
- Runnable: 表示一个没有返回值的任务,适用于不需要结果的场景。
- Callable: 表示一个可以返回结果的任务,适用于需要获取执行结果的场景。
- Future: 表示任务的执行结果,可以用来检查任务状态、获取结果或取消任务。
线程 start 和 run 的区别
run
方法是线程的执行体,包含线程要执行的代码,直接调用run
方法时,它会在当前线程的上下文中执行,而不是创建新的线程start
方法用于启动一个新的线程,并在新线程中执行run
方法的代码。调用start
方法会为线程线程分配系统资源,并将线程置于就绪状态,当调度器选择该线程时,会执行run
方法中的代码
注意:
如果多次调用
start()
,同一个线程对象不能多次调用start()
方法。多次调用会抛出IllegalThreadStateException
。底层源码如下javapublic void start() { synchronized (this) { // zero status corresponds to state "NEW". if (holder.threadStatus != 0) // 记录线程是否已启动 throw new IllegalThreadStateException(); start0(); } }
Java中有哪些锁
锁的种类
公平锁 / 非公平锁
可重入锁
独享锁 / 共享锁
互斥锁 / 读写锁
乐观锁 / 悲观锁
分段锁
偏向锁 / 轻量级锁 / 重量级锁
自旋锁
公平锁 / 非公平锁
**公平锁 **指多个线程按照 申请锁的顺序 来获取锁
非公平锁 指多个线程获取锁的顺序不按照 申请锁的顺序,可能出现后申请的线程比先申请的线程 优先获取锁。可能导致 优先级反转 或 饥饿现象
对于
Java ReentrantLock
而言,通过构造函数指定该锁是否是公平锁,默认非公平锁。非公平锁的 优点 在于 吞吐量比公平锁大。以下提供部分源码java/** * 非公平锁的同步对象 * NonfairSync 类实现了非公平锁的同步策略。 * 在非公平锁中,线程获取锁的顺序不保证,可能导致某些线程长时间等待。 */ static final class NonfairSync extends Sync { private static final long serialVersionUID = 7316153563782823691L; /** * 初次尝试获取锁的方法。 * 如果当前锁状态为 0(即未被锁定),尝试将状态设置为 1(表示锁定), * 并将当前线程设置为独占拥有线程。 * 如果当前线程已经是独占拥有线程,则增加锁的持有计数。 * 溢出检查是为了防止占有锁的线程多次获取锁时, * 导致锁的重入计数增加并超过Integer.MAX_VALUE,即2^31 - 1,超过后会变成负数 * @return 如果成功获取锁,则返回 true;否则返回 false。 */ final boolean initialTryLock() { Thread current = Thread.currentThread(); if (compareAndSetState(0, 1)) { // 第一次尝试不加保护 setExclusiveOwnerThread(current); return true; } else if (getExclusiveOwnerThread() == current) { int c = getState() + 1; if (c < 0) // 溢出检查 throw new Error("超过最大锁计数"); setState(c); return true; } else return false; } /** * 尝试获取锁的方法。 * 如果当前锁状态为 0,尝试将状态设置为指定的获取数量, * 并将当前线程设置为独占拥有线程。 * @param acquires 请求的锁的数量。 * @return 如果成功获取锁,则返回 true;否则返回 false。 */ protected final boolean tryAcquire(int acquires) { if (getState() == 0 && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } } /** * 公平锁的同步对象 * FairSync 类实现了公平锁的同步策略。 * 在公平锁中,线程获取锁的顺序遵循先来先服务的原则,避免线程饥饿。 */ static final class FairSync extends Sync { private static final long serialVersionUID = -3000897897090466540L; /** * 初次尝试获取锁的方法。 * 如果当前锁状态为 0 且没有线程在等待,则尝试将状态设置为 1, * 并将当前线程设置为独占拥有线程。 * @return 如果成功获取锁,则返回 true;否则返回 false。 */ final boolean initialTryLock() { Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (!hasQueuedThreads() && compareAndSetState(0, 1)) { setExclusiveOwnerThread(current); return true; } } else if (getExclusiveOwnerThread() == current) { if (++c < 0) // 溢出检查 throw new Error("超过最大锁计数"); setState(c); return true; } return false; } /** * 尝试获取锁的方法。 * 仅当当前锁状态为 0 且当前线程没有前驱线程等待时, * 尝试将状态设置为指定的获取数量,并将当前线程设置为独占拥有线程。 * @param acquires 请求的锁的数量。 * @return 如果成功获取锁,则返回 true;否则返回 false。 */ protected final boolean tryAcquire(int acquires) { if (getState() == 0 && !hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } } /** * 创建一个 {@code ReentrantLock} 实例。 * 默认情况下,创建的是非公平锁。 */ public ReentrantLock() { sync = new NonfairSync(); } /** * 创建一个具有给定公平性策略的 {@code ReentrantLock} 实例。 * @param fair {@code true} 表示创建公平锁;{@code false} 表示创建非公平锁。 */ public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }
对于
Synchronized
而言, 也是一种非公平锁。由于其不像ReentrantLock
是通过AQS框架来实现线程调度,所以没有任何办法使变成公平锁可重入锁
可重入锁又名递归锁,指同一个线程在外层方法获取锁的时候,再进入内层方法会自动获取锁。
对于
Java ReentrantLock
而言,就是一个可重入锁。对于
Synchronized
而言,也是一个可重入锁。可重入锁的好处就是可以一定程度上避免死锁。下列给出示例。上述
ReentrantLock
源码中的溢出检测也是由于可重入的特性。javapublic class ReentrantLockExample { private final Object lock = new Object(); public void outerMethod() { synchronized (lock) { System.out.println("进入外层方法"); innerMethod(); // 调用内层方法 System.out.println("离开外层方法"); } } public void innerMethod() { synchronized (lock) { System.out.println("进入内层方法"); // 执行内层方法的操作 System.out.println("离开内层方法"); } } public static void main(String[] args) { ReentrantLockExample example = new ReentrantLockExample(); example.outerMethod(); // 调用外层方法 } } /* 输出 进入外层方法 进入内层方法 离开内层方法 离开外层方法 */
独享锁 / 共享锁
独享锁:是指该锁一次只能被一个线程所持有。
共享锁:是指该锁可被多个线程所拥有
Java ReetrantLock
、Synchronized
:独享锁ReadWriteLock
:读锁是共享锁,写锁是独享锁读锁的共享可保证并发读的高效性,读写、写读、写写的过程都是互斥的
独享锁与共享锁也是通过AQS来实现的
乐观锁 / 悲观锁
乐观锁和悲观锁并不是具体类型的锁,而是指对待并发同步的角度
乐观锁:对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会尝试更新,不断重新的方式更新数据。乐观的认为不加锁的并发操作是没有事情的
悲观锁:对于同一个数据的并发操作,一定会发生修改的,哪怕可能没有修改,也会断定为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为不加锁的并发操作一定会出问题
乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升
悲观锁适合写操作非常多的场景
乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋,实现原子操作的更新。
补充:CAS算法
CAS(Compare And Swap)是一种常用于多线程并发编程的原子操作,旨在解决在多线程环境下对共享变量进行操作时可能出现的数据不一致问题。
CAS 操作的基本原理:
CAS 操作涉及三个操作数:
- 内存位置(V): 需要进行操作的内存地址。
- 预期原值(A): 期望内存位置当前的值。
- 新值(B): 如果内存位置的当前值等于预期原值,则将其更新为新值。
CAS 操作的执行过程如下:
- 如果内存位置 V 的当前值等于预期原值 A,则将 V 的值更新为新值 B。
- 如果 V 的当前值不等于 A,则不进行任何操作。
整个过程是原子性的,即不可中断,确保了在多线程环境下的安全性。
CAS 的应用:
CAS 被广泛应用于实现无锁数据结构和算法,例如无锁队列和无锁栈。通过 CAS,可以在不使用传统锁机制(如互斥锁)的情况下,实现线程安全的数据操作,提高并发性能。
CAS 的优缺点:
- 优点:
- 高效性: 由于避免了线程阻塞和上下文切换,CAS 操作通常比使用锁机制更高效。
- 无锁性: CAS 允许多个线程并发操作同一数据而无需锁定,提高了系统的并发度。
- 缺点:
- ABA 问题: 如果一个变量的值从 A 变为 B,再变回 A,CAS 操作可能无法检测到这种变化,导致错误。
- 自旋开销: 当多个线程频繁竞争同一变量时,CAS 操作可能导致大量自旋,消耗 CPU 资源。
分段锁
分段锁是一种锁的设计,对于
ConcurrentHashMap
(下列所述的ConcurrentHashMap
都是JDK1.7版本,JDK1.8后改用另一种形式完成线程安全)而言,其并发的实现就是通过分段锁的形式。我们以
ConcurrentHashMap
来说明分段锁的含义以及设计思想ConcurrentHashMap
中的分段锁称为Segment
,它既类似于HashMap
(JDK7与JDK8中HashMap
的实现)的结构,即内部拥有一个Entry
数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock
(Segment
继承了ReentrantLock
)。下列是
ConcurrentHashMap
(JDK1.7) 中Segment
类的源码javastatic final class Segment<K, V> extends ReentrantLock implements Serializable { /* * 段(Segment)维护一张条目列表的表,这些列表始终保持一致的状态, * 因此可以在不加锁的情况下通过对 segments 和 tables 的 volatile 读取进行读取。 * 这需要在表调整大小时复制节点,以便仍使用旧版本表的读取器可以遍历旧列表。 * * 本类仅定义需要加锁的修改方法。 * 除非另有说明,否则本类的方法执行 ConcurrentHashMap 方法的每个段版本。 * 这些修改方法通过 scanAndLock 和 scanAndLockForPut 方法使用一种受控的自旋方式来处理争用。 * 这些方法在获取锁的同时进行遍历,以定位节点。 * 主要目的是在获取锁时减少缓存未命中的影响, * 一旦获得锁,遍历会更快。我们实际上不使用找到的节点, * 因为它们必须在锁下重新获取,以确保更新的顺序一致性(并且可能已过时), * 但它们通常更容易重新定位。 * 此外,scanAndLockForPut 方法在未找到节点时,预先创建一个新节点,以便在 put 操作中使用。 */ private static final long serialVersionUID = 2249069246763182397L; /** * 预扫描中尝试获取锁的最大次数。 * 在多处理器系统上,使用有限次数的重试可以在定位节点时保持缓存一致性。 */ static final int MAX_SCAN_RETRIES = Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1; /** * 每个段的哈希表。元素通过 entryAt/setEntryAt 方法提供 volatile 语义进行访问。 */ transient volatile HashEntry<K, V>[] table; /** * 元素数量。仅在加锁或其他保持可见性的 volatile 读取中访问。 */ transient int count; /** * 此段的修改操作总数。 * 即使该值可能溢出 32 位,但它在 ConcurrentHashMap 的 isEmpty() 和 size() 方法中的稳定性检查中提供了足够的准确性。 * 仅在加锁或其他保持可见性的 volatile 读取中访问。 */ transient int modCount; /** * 当哈希表大小超过此阈值时,表将重新哈希。 * (此字段的值始终为 (int)(capacity * loadFactor))。 */ transient int threshold; /** * 哈希表的负载因子。 * 尽管所有段使用相同的负载因子,但将其复制以避免需要链接到外部对象。 * @serial */ final float loadFactor; /** * 构造方法,初始化段的负载因子、阈值和哈希表。 * * @param lf 哈希表的负载因子 * @param threshold 哈希表的阈值 * @param tab 哈希表 */ Segment(float lf, int threshold, HashEntry<K, V>[] tab) { this.loadFactor = lf; this.threshold = threshold; this.table = tab; } }
为了更好的理解Segment的应用,我们再来分析一下
ConcurrentHashMap
中Segment
类的(JDK7,下面补充中提供了JDK8的源码)中的put
方法(大家也可以拿上面Java集合类中 问题7 中的HashMap的putVal
方法(putVal
方法是JDK1.8开始引入的,用于简化put
和putIfAbsent
方法的实现)进行比较)。(每个Segment
都是一个独立的哈希表,并负责管理其中的一部分数据。这种设计通过减少锁的粒度来提高并发性能。)javafinal V put(K key, int hash, V value, boolean onlyIfAbsent) { // 尝试获取当前段的锁。如果获取失败,则调用 scanAndLockForPut 方法进行处理。 HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value); V oldValue; try { // 获取当前段的哈希表。 HashEntry<K,V>[] tab = table; // 计算给定哈希值在哈希表中的索引位置。 int index = (tab.length - 1) & hash; // 获取索引位置处的链表头节点。 HashEntry<K,V> first = entryAt(tab, index); // 遍历链表,查找给定键是否已存在。 for (HashEntry<K,V> e = first;;) { if (e != null) { K k; // 如果找到匹配的键,保存旧值,并根据 onlyIfAbsent 参数决定是否更新值。 if ((k = e.key) == key || (e.hash == hash && key.equals(k))) { oldValue = e.value; if (!onlyIfAbsent) { e.value = value; ++modCount; } break; } e = e.next; } else { // 如果未找到匹配的键,创建新节点并插入到链表头部。 if (node != null) node.setNext(first); else node = new HashEntry<K,V>(hash, key, value, first); int c = count + 1; // 检查是否需要调整哈希表的大小。 if (c > threshold && tab.length < MAXIMUM_CAPACITY) rehash(node); else setEntryAt(tab, index, node); ++modCount; count = c; oldValue = null; break; } } } finally { // 确保在操作完成后释放锁。 unlock(); } // 返回旧值,如果键之前不存在,则返回 null。 return oldValue; }
总结:
总的来说,分段锁通过将数据划分为多个段,并为每个段配备独立的锁,减少了锁竞争,提高了并发访问的效率。这种设计在需要高并发访问的数据结构中,如早期的
ConcurrentHashMap
,得到了广泛应用。补充:JDK1.8前的
ConcurrentHashMap
采用的是ReentrantLock + Segment + HashEntry
的方法JDK1.8开始变为
synchronized + CAS + HashEntry + 红黑树
。javafinal V putVal(K key, V value, boolean onlyIfAbsent) { // 检查 key 或 value 是否为 null,若是,则抛出 NullPointerException if (key == null || value == null) throw new NullPointerException(); // 计算 key 的哈希值,并进行扰动处理 int hash = spread(key.hashCode()); int binCount = 0; // 遍历哈希表,直到成功插入元素或找到已存在的元素 for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; K fk; V fv; // 如果哈希表为空,初始化哈希表 if (tab == null || (n = tab.length) == 0) tab = initTable(); // 计算哈希值对应的桶索引,并获取该位置的第一个节点 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // 如果该位置为空,尝试使用 CAS 将新节点插入 if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; // 在添加到空桶时无需加锁 } // 如果节点的哈希值为 MOVED,表示哈希表正在迁移,需要帮助迁移 else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); // 如果 onlyIfAbsent 为 true,且第一个节点的哈希值和 key 匹配,且值非空 else if (onlyIfAbsent && fh == hash && ((fk = f.key) == key || (fk != null && key.equals(fk))) && (fv = f.val) != null) return fv; else { V oldVal = null; // 对节点 f 加锁,确保在修改链表或树结构时的线程安全 synchronized (f) { // 再次检查节点 f 是否未被修改 if (tabAt(tab, i) == f) { // 如果节点是链表形式 if (fh >= 0) { binCount = 1; // 遍历链表,查找匹配的 key for (Node<K,V> e = f;; ++binCount) { K ek; // 如果找到匹配的 key,更新值或插入新节点 if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { oldVal = e.val; if (!onlyIfAbsent) e.val = value; break; } Node<K,V> pred = e; // 如果到达链表末尾,添加新节点 if ((e = e.next) == null) { pred.next = new Node<K,V>(hash, key, value, null); break; } } } // 如果节点是红黑树形式 else if (f instanceof TreeBin) { Node<K,V> p; binCount = 2; // 将新节点插入红黑树 if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) { oldVal = p.val; if (!onlyIfAbsent) p.val = value; } } // 如果节点是预留节点,抛出异常 else if (f instanceof ReservationNode) throw new IllegalStateException("递归更新"); } } // 如果链表长度超过阈值,转换为红黑树 if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); // 返回旧值(如果存在) if (oldVal != null) return oldVal; break; } } } // 更新哈希表的元素数量统计信息 addCount(1L, binCount); return null; }
偏向锁 / 轻量级锁 / 重量级锁
这三个是指锁的状态,并且是针对
Synchronized
。- 偏向锁:
- 设计目的:优化在无锁竞争的情况下,同一线程多次获取同一锁的场景,减少不必要的同步操作。
- 实现机制:当一个线程首次获取对象锁时,锁会偏向该线程,即在对象头(对象在内存中存储布局的一部分,包含对象的元数据信息)的 Mark Word 中记录该线程的 ID。如果同一线程再次进入同步块,无需进行同步操作,只需检查对象头中的线程 ID 是否与当前线程匹配即可。
- 锁升级:当另一个线程尝试获取已偏向的锁时,偏向锁会被撤销,锁状态升级为轻量级锁,以适应多线程竞争的情况。
- 轻量级锁:
- 设计目的:在存在短时间锁竞争的情况下,使用 CAS(Compare-And-Swap)操作来避免重量级锁的开销。
- 实现机制:当一个线程尝试获取锁时,会在其栈帧中创建一个锁记录(Lock Record),并将对象头的 Mark Word 复制到其中。然后,线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果替换成功,则当前线程获得锁;如果失败,则表示存在竞争,线程会尝试自旋获取锁。
- 锁升级:当自旋获取锁失败时,表示存在其他线程竞争锁(两个或两个以上线程竞争同一个锁),则轻量级锁会膨胀成重量级锁。
- 重量级锁:
- 设计目的:在多线程竞争激烈的情况下,确保线程以阻塞的方式安全地访问同步代码块。
- 实现机制:重量级锁依赖于每个对象内部的监视器锁(Monitor)来实现,而监视器又依赖于操作系统的互斥锁(Mutex)。当线程获取不到锁时,会被阻塞,操作系统需要在用户态和内核态之间进行线程切换,开销较大。
补充:
JDK 1.5 之前:
在 JDK 1.5 之前,
synchronized
被视为重量级锁,其实现依赖于操作系统的互斥锁(Mutex)。每次线程获取或释放锁时,都需要在用户态和内核态之间切换,导致较高的性能开销。JDK 1.5:
为了提高并发性能,JDK 1.5 引入了
java.util.concurrent
包,其中包含了Lock
接口和相关的并发工具。这些新工具提供了比synchronized
更灵活的锁机制,允许开发者手动控制锁的获取和释放,以减少不必要的上下文切换。JDK 1.6 及之后:
从 JDK 1.6 开始,Java 对
synchronized
进行了深入的优化,引入了多种锁优化技术,如偏向锁、轻量级锁和自适应自旋锁等。- 偏向锁:用于优化在无锁竞争的情况下,同一线程多次获取同一锁的场景,减少不必要的同步操作。
- 轻量级锁:在存在短时间锁竞争的情况下,使用 CAS 操作来避免重量级锁的开销。
- 自适应自旋锁:在短暂的锁竞争情况下,线程会自旋等待而不立即阻塞,以减少线程上下文切换的开销。
- 偏向锁:
自旋锁
自旋锁(spin lock)是一种用于多线程同步的锁机制。当一个线程尝试获取自旋锁时,如果锁已被其他线程持有,该线程不会进入休眠状态,而是持续循环检查锁的状态,直到成功获取锁为止。由于线程在此过程中保持执行,因此这种方式被称为“忙等待”。
自旋锁的特点:
- 忙等待:线程在等待锁释放时不会被阻塞,而是持续占用CPU资源进行锁状态的检查。
- 适用场景:自旋锁适用于锁持有时间短、线程不会长时间阻塞的情况。在这种情况下,自旋等待可能比线程挂起和唤醒的上下文切换开销更低。
- 不适用于单核处理器:在单核处理器上,自旋锁可能导致死锁。因为持有锁的线程和等待锁的线程无法在同一时间并行执行,等待线程会一直占用CPU,导致持有锁的线程无法执行,从而无法释放锁
Java线程安全的实现
阻塞同步:
- 内置锁(
synchronized
): 使用synchronized
关键字来修饰方法或代码块,实现对共享资源的互斥访问。每个对象都有一个内置锁,线程在进入同步代码块前需要获取该锁,执行完毕后释放锁。 - 显式锁(如
ReentrantLock
): Java 并发包(JUC)提供的锁实现,功能更强大,如可尝试获取锁、定时获取锁等,但需要手动释放锁,容易导致死锁等问题。
缺点:
- 性能开销: 锁的获取和释放涉及上下文切换,可能导致性能下降。
- 死锁风险: 多个线程持有不同锁并等待对方释放,可能导致系统停滞。
- 内置锁(
非阻塞同步:
使用锁的过程中,频繁的线程阻塞、唤醒操作,以及用户态、内核态的来回切换会带来性能问题。可能这些额外的操作带来的时间消耗远大于线程自身的业务执行时间。所以引入非阻塞同步,也就是基于
CAS
操作通过原子操作(如 CAS)来实现线程安全,避免了线程阻塞和上下文切换的开销。主要实现方式包括:
- 原子类: JUC 提供的原子变量类(如
AtomicInteger
、AtomicReference
)利用底层硬件的原子操作实现高效的线程安全操作。 - 自旋锁: 线程在获取锁失败时,持续循环尝试获取锁,而不是被阻塞。适用于锁持有时间短的场景,但会消耗大量 CPU 资源。
缺点:
- 自旋开销: 自旋锁在锁竞争激烈时会浪费大量 CPU 时间。
- 适用场景有限: 非阻塞同步适用于特定场景,如简单的计数器更新,对于复杂的同步需求,仍需使用阻塞同步。
- 原子类: JUC 提供的原子变量类(如
无同步方案:
在并发编程中,无同步方案旨在避免使用传统的同步机制(如锁)来减少性能开销。除了之前提到的非阻塞同步(基于CAS操作)外,还有以下常见的无同步方案:
乐观锁(Optimistic Locking):
乐观锁假设多个线程对数据的操作不会发生冲突,因此在操作前不加锁,而是在提交时检查数据是否被其他线程修改过。常见实现方式包括版本号控制和时间戳机制。
无锁编程(Lock-Free Programming):
无锁编程通过原子操作确保多个线程在并发访问共享数据时不会导致不一致性。与阻塞同步不同,无锁操作不会导致线程挂起,从而提高性能。
读写锁(Read-Write Lock):
读写锁允许多个线程同时读取共享数据,但在写操作时需要独占访问权限。这种方式适用于读多写少的场景,可以提高并发性能。
需要注意的事项:
- 适用场景有限: 无同步方案适用于特定场景,如简单的计数器更新,对于复杂的同步需求,仍需使用阻塞同步。
- 自旋开销: 自旋锁在锁竞争激烈时会浪费大量 CPU 时间。
补充:
ABA问题:
ABA问题指的是在使用比较并交换(CAS)操作时,变量的值从A变为B,又从B变回A,CAS操作无法察觉这种变化,可能导致程序出现意料之外的行为
示例:
- 初始状态: 变量
x
的值为A。 - 线程1操作: 线程1将
x
的值从A修改为B。 - 线程2操作: 线程2读取
x
的值为A,进行处理后,将x
的值从A修改为C。 - 线程1恢复: 线程1将
x
的值从B修改回A。 - 线程2提交: 线程2将
x
的值从A修改为C。
在上述过程中,CAS操作可能认为
x
的值没有变化,导致线程2的修改被覆盖,产生不一致的结果。解决方法:
- 引入版本号或时间戳: 在变量值的基础上,增加版本号或时间戳,每次修改时更新。CAS操作不仅比较值,还比较版本号或时间戳,确保操作的正确性。
- 初始状态: 变量
怎么理解 synchronized
在 Java
中, synchronized
是一个关键字,用于实现线程同步
synchronized
的实现是基于软件层面上的JVM
synchronized
能修饰实例方法、修饰静态方法、修饰代码块java// 修饰实例方法 public class Counter { private int count = 0; public synchronized void increment() { count++; } public synchronized int getCount() { return count; } } // 修饰静态方法 public class Counter { private static int count = 0; public static synchronized void increment() { count++; } public static synchronized int getCount() { return count; } } // 修饰代码块 public class Counter { private int count = 0; private final Object lock = new Object(); public void increment() { synchronized (lock) { count++; } } public int getCount() { synchronized (lock) { return count; } } }
互斥访问: 当多个线程访问同一资源时,
synchronized
确保同一时刻只有一个线程能访问该资源,防止数据不一致和竞争条件。内存可见性: 使用
synchronized
修饰的方法或代码块,可以保证一个线程对共享变量的修改对其他线程可见,避免由于缓存导致的数据不一致问题。
synchronized
和 lock
的区别是什么
1. synchronized
关键字:
- 机制:
- Java内置的同步机制,基于JVM的监视器锁(monitor lock)实现。
- 灵活性:
- 用于方法或代码块,灵活性相对较低。
- 等待与通知:
- 与
wait()
和notify()/notifyAll()
方法一起使用,实现线程的等待和通知。
- 与
2. java.util.concurrent.locks
包下的锁(如ReentrantLock
):
- 机制:
- 显示锁机制,需要手动获取和释放锁。
- 灵活性:
- 提供更多灵活性,如尝试获取锁、可中断获取锁、设置公平性等。
- 等待与通知:
- 可以与
Condition
接口结合,实现更细粒度的线程等待和通知机制。
- 可以与
3. java.util.concurrent
包下的其他同步工具:
- 机制:
- 提供高级的并发工具,如
CountDownLatch
、CyclicBarrier
、Semaphore
等,简化复杂同步场景的实现。
- 提供高级的并发工具,如
- 灵活性:
- 根据具体工具的功能,提供不同程度的灵活性和控制。
- 等待与通知:
- 大多数工具内置了等待和通知机制,使用起来更加直观。
4. 原子变量类(如AtomicInteger
):
- 机制:
- 基于CAS(比较并交换)操作,实现无锁的线程安全。
- 灵活性:
- 适用于简单的计数器等场景,但对于复杂的同步需求,可能需要结合其他同步机制。
- 等待与通知:
- 不提供等待和通知机制,主要用于简单的原子操作。
5. volatile
关键字:
- 机制:
- 保证变量的可见性,但不保证原子性。适用于状态标志等场景。
- 灵活性:
- 限制较多,主要用于确保共享变量的可见性。
- 等待与通知:
- 不涉及等待和通知机制。
总结:
synchronized
:适用于简单的同步场景,使用方便,但灵活性较差。ReentrantLock
:提供更高级的锁功能,如公平性、可中断等,适用于需要精细控制的场景。- 其他工具:如
CountDownLatch
、Semaphore
等,适用于特定的同步需求,简化复杂场景的实现。 - 原子变量类:适用于简单的原子操作,如计数器等,但不适用于复杂的同步需求。
volatile
:用于确保变量的可见性,但不适用于需要原子性的场景。
volatile 关键字的作用有哪些
首先,volatile
关键字只能用于修饰变量,主要有以下作用:
保证可见性:
- 当一个变量被
volatile
修饰后,任何线程对该变量的修改都会立即同步到主内存,确保其他线程读取到的是最新的值。这解决了多线程环境下,线程对共享变量的修改可能在其他线程中不可见的问题。 - 实现原理:
- 对于非
volatile
变量进行读写的时候,每个线程都会到主内存中拷贝变量到CPU
缓存中,当计算机有多个CPU
时,意味着每个线程可以拷贝到不同的CPU Cache
volatile
变量不会被缓存在寄存器或者对其它处理器不可见的地方,保证每次读写变量都从主存中读。
- 对于非
- 当一个变量被
禁止指令重排:
volatile
关键字禁止JVM和处理器对其修饰的变量进行指令重排优化,确保代码执行的顺序性。这对于一些需要按照特定顺序执行的操作非常重要。补充:
指令重排的类型:
- 编译器重排: 编译器在生成字节码时,为了优化性能,可能会调整指令的顺序。
- 处理器重排: 处理器在执行指令时,可能出于性能考虑,对指令执行顺序进行调整。
- JVM重排: JVM在执行字节码时,可能会对指令进行重排,以提高执行效率。
指令重排带来的问题:
在多线程环境下,指令重排可能导致以下问题:
- 共享变量的可见性问题: 线程对共享变量的修改可能不会立即对其他线程可见,导致数据不一致。
- 程序执行结果不符合预期: 由于指令执行顺序的变化,可能导致程序行为与开发者的预期不一致。
开发者如何避免指令重排带来的问题:
- 使用
volatile
关键字: 在多线程环境中,使用volatile
修饰共享变量,确保对该变量的读写操作直接作用于主内存,避免线程间缓存的不一致性。 - 使用同步机制: 通过
synchronized
等同步机制,确保对共享资源的访问是互斥的,避免由于指令重排导致的数据不一致问题。
保证原子性(对于单一读写操作):
- 对于
volatile
修饰的变量,读写操作具有原子性,即不会被中断或分割。然而,需要注意的是,复合操作(如i++
)并不具备原子性,仍需使用其他同步机制来保证。
补充:
使用
volatile
的变量在进行复合操作(如i++
)时为什么不具备原子性- 复合操作不仅仅是对变量的一次简单读或写,而是包含了读取当前值、计算新值以及写回新值的多个步骤,这些步骤必须作为一个不可分割的整体来执行,以确保操作的原子性
volatile
仅保证对单一变量的读写操作具有可见性,但它不涉及对多个操作步骤的原子性控制。涉及到的多个步骤可能会被其它线程的操作打断,从而导致数据不一致或竞争条件volatile
不提供同步机制,也不阻止其它线程对变量的访问,也不保证操作的顺序性。因此进行复合操作的时候,需要显式的同步机制来保证操作的原子性
示例:
javaprivate volatile int counter = 0; public void increment() { counter++; // 非原子操作 }
该操作分为以下步骤:
- 读取
counter
的当前值。 - 将读取的值加一。
- 将计算结果写回
counter
。
如果在这三个步骤之间,其他线程对
counter
进行了修改,就可能导致最终结果不正确。解决方法:
为了确保自增或自减操作的原子性,可以使用显式的同步机制,例如:
javaprivate int counter = 0; public synchronized void increment() { counter++; }
或者使用
java.util.concurrent.atomic
包下的原子类,如AtomicInteger
,它提供了原子性的自增操作:javaprivate AtomicInteger counter = new AtomicInteger(0); public void increment() { counter.incrementAndGet(); }
- 对于
什么是线程池,线程池有什么用,线程太多会怎么样
**线程池(Thread Pool)**是一种用于管理和复用线程资源的设计模式。它预先创建一定数量的线程,并将其保存在池中。当有任务需要执行时,线程池会从池中取出一个空闲线程来执行任务,任务执行完毕后,线程不会被销毁,而是返回线程池,等待下一个任务的到来。
线程池的主要优点:
- 降低资源消耗: 通过复用线程池中的线程,避免了频繁创建和销毁线程所带来的性能开销。
- 提高响应速度: 任务提交后,无需等待线程创建,线程池中的线程可以立即执行任务,缩短响应时间。
- 提高线程管理能力: 线程池提供了统一的管理方式,可以方便地控制线程的数量、状态等,有助于系统的维护和监控。
线程池的工作机制:
- 任务提交: 当任务需要执行时,将任务提交给线程池。
- 任务调度: 线程池维护一个任务队列,将提交的任务放入队列中。
- 线程分配: 线程池中的工作线程从任务队列中取出任务并执行。
- 任务完成: 任务执行完毕后,线程返回线程池,等待下一个任务。
线程过多的具体影响:
- 资源消耗:
- 内存占用: 每个线程都需要分配一定的内存空间,主要用于存储线程栈等信息。大量线程的存在会消耗大量内存资源,可能导致内存溢出。
- CPU 资源: 虽然多线程可以提高并发度,但过多线程会导致 CPU 频繁进行上下文切换,增加调度开销,反而降低系统性能。
- 上下文切换开销:
- 线程切换需要保存和加载线程的上下文信息,包括寄存器、程序计数器等。大量线程的频繁切换会增加 CPU 的负担,影响系统响应速度和吞吐量。
- 线程管理复杂性:
- 大量线程的创建和销毁增加了线程管理的复杂性,可能导致线程泄漏(线程执行完毕后未被正确终止或回收)、死锁等问题,影响系统的稳定性和可维护性。
- 资源消耗:
Java中线程池的使用:
在Java中,可以使用
java.util.concurrent
包提供的ExecutorService
接口及其实现类来创建和管理线程池。常用的线程池实现类包括:FixedThreadPool: 创建一个固定大小的线程池,线程池中的线程数量在整个生命周期内保持不变。适用于负载比较稳定的场景。
javaimport java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class ThreadPoolExample { public static void main(String[] args) { // 创建一个固定大小的线程池 ExecutorService executorService = Executors.newFixedThreadPool(4); // 提交多个任务 for (int i = 0; i < 10; i++) { executorService.submit(new RunnableTask(i)); } // 关闭线程池 executorService.shutdown(); } } class RunnableTask implements Runnable { private int taskId; public RunnableTask(int taskId) { this.taskId = taskId; } @Override public void run() { System.out.println("执行任务:" + taskId + ",线程名称:" + Thread.currentThread().getName()); } }
CachedThreadPool: 创建一个可缓存的线程池,根据需要创建新线程,空闲线程会在60秒后被终止并从缓存中移除。适用于执行很多短期异步任务的场景。
javaimport java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class CachedThreadPoolExample { public static void main(String[] args) { // 创建一个缓存线程池 ExecutorService executorService = Executors.newCachedThreadPool(); // 提交多个任务 for (int i = 1; i <= 5; i++) { executorService.submit(new Task("Task " + i)); } // 关闭线程池 executorService.shutdown(); } } class Task implements Runnable { private final String name; public Task(String name) { this.name = name; } @Override public void run() { System.out.println(name + " is being executed by " + Thread.currentThread().getName()); try { // 模拟任务执行时间 Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } } }
SingleThreadExecutor: 创建一个单线程化的线程池,只有一个工作线程来执行任务,保证任务的顺序执行。
javaimport java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class SingleThreadExecutorExample { public static void main(String[] args) { // 创建一个单线程的线程池 ExecutorService executorService = Executors.newSingleThreadExecutor(); // 提交多个任务 executorService.submit(new Task("Task 1")); executorService.submit(new Task("Task 2")); executorService.submit(new Task("Task 3")); // 关闭线程池 executorService.shutdown(); } } class Task implements Runnable { private final String name; public Task(String name) { this.name = name; } @Override public void run() { System.out.println(name + " is being executed by " + Thread.currentThread().getName()); try { // 模拟任务执行时间 Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } } }
ScheduledThreadPool: 创建一个定长线程池,支持定时及周期性任务的执行。
javaimport java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; public class ScheduledThreadPoolExample { public static void main(String[] args) { // 创建一个具有固定线程数的调度线程池 ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); // 创建一个任务 Runnable task = () -> { System.out.println("任务执行时间: " + System.currentTimeMillis()); }; // 延迟 3 秒后执行任务,之后每隔 5 秒执行一次 scheduler.scheduleAtFixedRate(task, 3, 5, TimeUnit.SECONDS); // 由于主线程需要等待任务执行,故加入休眠 try { Thread.sleep(20000); // 主线程休眠 20 秒 } catch (InterruptedException e) { e.printStackTrace(); } // 关闭调度器 scheduler.shutdown(); } }
线程池的常用参数
corePoolSize
: 核心线程数作用: 设置线程池维护的核心线程数量。即使这些线程处于空闲状态,线程池也会保持它们的存在,除非设置了
allowCoreThreadTimeOut
为true
。maxmumPoolSize
:最大线程数作用: 设置线程池允许的最大线程数量。当任务数量超过核心线程数且任务队列已满时,线程池会创建新的线程,直到达到最大线程数。
keepAliveTime
:空闲线程存活时间作用: 设置非核心线程在完成任务后,最多保持空闲状态的时间。当超过此时间,空闲线程会被终止,直到线程池中线程数量等于核心线程数。
TimeUnit
:时间单位作用: 定义
keepAliveTime
参数的时间单位,如秒、毫秒等。BlockingQueue
:线程池任务队列作用: 用于存储待执行任务的队列。常用的队列类型有:
ArrayBlockingQueue
: 有界阻塞队列,按照先进先出(FIFO)原则对元素进行排序。LinkedBlockingQueue
: 可选有界或无界阻塞队列,适用于任务生产速度与消费速度不一致的场景。SynchronousQueue
: 无缓冲区队列,每个插入操作必须等待另一个线程的删除操作。
ThreadFactory
:创建线程的工厂线程池创建线程时调用的工厂方法,通过此方法可以设置线程的优先级、线程命名规则以及线程类型(用户线程还是守护线程)等
RejectedExecutionHandler
:拒绝策略当线程池的任务超出线程池队列可以存储的最大值之后执行的策略。常用的拒绝策略有:
AbortPolicy
: 默认策略,直接抛出RejectedExecutionException
。CallerRunsPolicy
: 由调用者线程处理该任务。DiscardPolicy
: 丢弃当前任务。DiscardOldestPolicy
: 丢弃最旧的任务。
**问题11:**BIO、NIO、AIO的区别
1. BIO(Blocking I/O) - 同步阻塞 I/O:
**比喻:**想象你正在银行办理业务。每当有客户来办理业务时,银行会指派一名柜员专门为其服务。其他客户需要排队等待,直到前面的客户办理完毕。这种方式下,每个客户都需要等待前一个客户完成,效率较低。
特性: 在 BIO 模型中,每个 I/O 操作都会阻塞当前线程,直到操作完成。这意味着线程在等待 I/O 操作完成期间无法执行其他任务。
适用场景: 适用于连接数目较少且固定的应用场景,例如传统的客户端-服务器架构。由于每个连接都需要一个独立的线程,线程数量过多可能导致资源耗尽。
2. NIO(Non-blocking I/O) - 同步非阻塞 I/O:
**比喻:**假设银行引入了排号系统。客户到达后,先取号,然后等待叫号。每当有客户办理完毕,银行会广播下一个号码,客户根据号码顺序办理业务。在等待过程中,客户可以自由活动,不需要一直站在柜台前。这种方式下,客户利用等待时间做其他事情,提高了效率。
特性: NIO 引入了缓冲区(Buffer)、通道(Channel(是双向通道))、选择器(Selector)的概念,允许单个线程管理多个 I/O 操作。线程可以在没有数据可读写时执行其他任务,避免了阻塞。
- 缓冲区(Buffer):
- NIO 使用缓冲区来存储数据,所有的数据读写操作都是通过缓冲区进行的。缓冲区是一个连续的内存块,提供了对数据的高效访问。
- Java 提供了多种类型的缓冲区,如
ByteBuffer
、CharBuffer
、IntBuffer
等,分别用于处理不同类型的数据。
- 通道(Channel):
- 通道是用于连接 I/O 设备(如文件、套接字)与缓冲区的媒介。通道支持异步 I/O 操作,可以在非阻塞模式下进行数据传输。常见的通道类型包括:
FileChannel
:用于文件数据的读写。SocketChannel
:用于网络套接字的数据传输。ServerSocketChannel
:用于监听和接受网络连接。DatagramChannel
:用于 UDP 数据报的读写。
- 通道是用于连接 I/O 设备(如文件、套接字)与缓冲区的媒介。通道支持异步 I/O 操作,可以在非阻塞模式下进行数据传输。常见的通道类型包括:
- 选择器(Selector):
- 选择器用于监控多个通道的事件(如连接请求、数据可读写等),实现单线程管理多个通道的功能。通过选择器,线程可以在多个通道之间进行切换,提高了系统的资源利用率和响应能力。
- 缓冲区(Buffer):
适用场景: 适用于连接数目较多且需要高并发处理的应用,例如聊天服务器或实时数据处理系统。
3. AIO(Asynchronous I/O) - 异步 I/O:
**比喻:**进一步假设,银行提供了预约服务。客户可以通过手机应用预约办理业务的时间,并在预约时间到达时收到提醒。这样,客户无需在银行排队等待,直接在指定时间办理业务,节省了大量时间。这种方式下,客户与银行的交互更加高效灵活。
特性: AIO 允许操作系统在后台完成 I/O 操作,并在完成时通知应用程序。应用程序无需等待 I/O 操作完成,可以继续执行其他任务。
适用场景: 适用于需要处理大量 I/O 操作且对响应时间要求严格的应用,例如高性能文件服务器。
总结:
- BIO: 简单易用,但在高并发场景下性能较差,适用于连接数目少且固定的场景。
- NIO: 通过非阻塞和多路复用技术,提高了 I/O 操作的性能,适用于高并发且连接时间较短的场景。
- AIO: 将 I/O 操作交给操作系统异步处理,进一步提高性能,适用于对响应时间要求高的高并发场景。