悲观锁:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁
乐观锁
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
Synchronized初始使用乐观锁策略.当发现锁竞争比较频繁时,就会自动切换成悲观锁
乐观锁的一个重要功能就是要检测出数据是否发生访问冲突,我们可以引入一个"版本号"来解决
假设我们需要多线程修改"用户账户余额"
设当前余额100,引入一个版本号version,初始值为1,并且我们规定"提交版本必须大于记录当前版本才能执行余额更新"1)线程A此时准备将其读出(version=1,balance=100),线程B也读出此信息(version(version=1,balance=100)
锁的核心特性"原子性",这样的机制追根溯源是cpu这样硬件设备提供的
这两个操作, 成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着要花很多时间
轻量级锁: 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex.
synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.
自旋锁
按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度.
但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题
自旋锁伪代码:
while (抢锁(lock) == 失败) {}
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来.
一旦锁被其他线程释放, 就能第一时间获取到锁.
自旋锁是一种典型的 轻量级锁 的实现方式
挂起等待锁
要借助OS的api来实现,一旦出现锁竞争,就会在内核中触发一系列动作(比如让这个线程进入阻塞状态,暂时不参与cpu调度)
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方和写入方之间以及写入方和读取方之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。
一个线程对与数据的访问,主要存在两个操作:读数据和写数据.
读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.
读加锁和读加锁之间, 不互斥
写加锁和写加锁之间, 互斥
读加锁和写加锁之间, 互斥
注意, 只要是涉及到 “互斥”, 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多久了.
因此尽可能减少 “互斥” 的机会, 就是提高效率的重要途径
读写锁特别适合于 “频繁读, 不频繁写” 的场景中. (这样的场景其实也是非常广泛存在的)
Synchronized 不是读写锁
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的
而 Linux 系统提供的 mutex 是不可重入锁
第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第
二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会 死锁 ,这样的锁称为 不可重入锁
synchronized 是可重入锁
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.
当线程 A 释放锁的时候, 会发生啥呢?
公平锁:遵循"先来后到",B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁:不遵守 “先来后到”. B 和 C 都有可能获取到锁.
注:
synchronized 是非公平锁.
悲观锁认为多个线程访问同一个共享变量冲突的概率较大,所以会在每次访问共享变量之前都会去真正加锁
乐观锁认为多个线程访问同一个共享变量冲突的概率较小,并不会真的加锁,而是直接尝试访问数据,在访问的同时识别当前的数据是否出现访问冲突
读写锁就是把读操作和写操作分别进行加锁
读锁和读锁之间不互斥
写锁和写锁之间互斥
写锁和读锁之间互斥
读写锁最主要在"频繁读,不频繁写"的场景中
如果获取锁失败,立即再尝试获取锁,无限循环,知道获取到锁为止.第一次获取锁失败,第二次的尝试会在极短时间内到来,一旦锁被其他线程释放,就能第一时间获取到锁
相比于挂起等待锁:
优点:没有放弃CPU资源,一旦锁被释放就能第一时间获取到锁, 更高效.在锁持有时间比较短的场景下非常有用
缺点:如果锁的持有时间较长,就会浪费cpu资源
是可重入锁
可重入锁指的就是连续两次加锁不会导致死锁
实现的方法是在锁中记录持有该锁的线程身份,以及一个计数器(记录加锁次数),如果发现当前加锁的线程就是持有锁的线程,则直接计数自增
CAS: 全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,寄存器中旧的预期值A,需要修改的新值B。
CAS伪代码
下面的代码不是原子的,真实的CAS是一个**原子的硬件指令(cpu指令)**完成的,这个伪代码只是辅助理解
CAS 的工作流程
单个cpu指令,是原子的,就可以使用CAS完成一些操作,进一步代替加锁
给编写线程安全的代码,引入新的思路
基于CAS实现线程安全的方式,也称为"无锁编程"
优点:保证线程安全,同时避免阻塞(效率)
缺点:1.代码更复杂,不好读
2.只能够适应一些特定场景,不如加锁方法普适
两种典型的不是"原子性"的代码
1.check and set (if判定然后设定值)
2.read and update (i++ lord add save)
CAS本质上是cpu提供的指令=>又被操作系统封装,提供成api=>又被JVM封装,也提供成api=>被程序员使用
针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲
简而言之,是因为硬件予以了支持,软件层面才能做到
在java中,有些操作是偏底层的操作,偏底层的操作在使用的时候有更多的注意事项
稍有不慎就容易写出问提
这些操作,就放到unsafe中进行归类
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
AtomicInteger atomicInteger = new AtomicInteger (0);
//相当于i++
atomicInteger.getAndIncrement ();
伪代码实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
假设两个线程同时调用getAndIncrement
两个线程都读取 value 的值到 oldValue 中. (oldValue 是一个局部变量, 在栈上. 每个线程有自己的栈)
线程1 先执行 CAS 操作. 由于 oldValue 和 value 的值相同, 直接进行对 value 赋值
注意:
CAS 是直接读写内存的, 而不是操作寄存器.
CAS 的读内存, 比较, 写内存操作是一条硬件指令, 是原子的
线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值. 因此需要进入循环.
在循环里重新读取 value 的值赋给 oldValue
线程2 接下来第二次执行 CAS, 此时 oldValue 和 value 相同, 于是直接执行赋值操作.
线程1 和 线程2 返回各自的 oldValue 的值即可.
native修饰的方法,成为"本地方法",也就是在JVM源码中,使用C++实现的逻辑,涉及到一些底层操作
基于 CAS 实现更灵活的锁, 获取到更多的控制权.
自旋锁伪代码
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
假设两个线程t1和t2,有一个共享变量num,初始值为A
解下来,t1想使用CAS把num值改为Z,那么就需要
但是,在t1执行这两个操作之间,t2可能把num值从A改成B,又从B改成了A
线程 t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这
个时候 t1 究竟是否要更新 num 的值为 Z 呢?
到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程
部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一些特殊情况
假设 我 有 100 存款. 我想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.正常的过程
1)存款100,线程1获取到当前存款值为100,期望更新为50;线程2获得当前存款值为100,期望更新为 50.
2) 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
3) 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败.*异常的过程
- 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
- 在线程2 执行之前, 我的朋友正好给我转账 50, 账户余额变成 100 !!
- 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作这个时候, 扣款操作被执行了两次!!!
给要修改的值,引入版本号,在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期
假设 我 有 100 存款. 我想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.
- 存款 100. 线程1 获取到 存款值为 100, 版本号为 1, 期望更新为 50; 线程2 获取到存款值为 100,版本号为 1, 期望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50, 版本号改为2. 线程2 阻塞等待中.
- 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100, 版本号变成3.
- 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 但是当前版本号为 3, 之前读到的版本号为 1, 版本小于当前版本, 认为操作失败
在java标准库中提供了AtomicStampedReference类 这个类可以对某个类进行包装,在内部就提供了上面描述的版本管理功能
1)讲一下你自己理解的CAS
CAS全程Compare and Swap,即比较并交换,相当于通过一个原子的操作,同时完成"读取内存,比较是否相等,修改内存"这三个步骤,本质上需要CPU指令的支撑
2)ABA问题怎么解决?
给要修改的数据引入版本号,在CAS比较数据当前值和旧值的同时也要比较版本号是否符合预期.如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当前版本号比之前读到的版本号大, 就认为操作失败
结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):
JVM将synchronized锁分为无锁,偏向锁,轻量级锁,重量级锁状态.会根据情况,一次进行升级
第一个尝试加锁的线程,优先进入偏向锁状态
偏向锁不是真的加锁,只是给对象头中做一个"偏向锁标记",记录这个锁属于哪个线程
如果后续没有其他线程来竞争锁,那么就不用进行其他同步操作了(避免加锁解锁开销)
如果后续有其他线程来竞争该锁,就取消原来的偏向锁状态,进入一般轻量级锁状态
偏向锁本质相当于"延迟加锁", 能不加锁就不加锁, 尽量来避免不必要的加锁开销
但是该做的标记还是得做的, 否则无法区分何时需要真正加锁
偏向锁的核心思想,就是"懒汉模式"的;另一种体现
线程池,是优化了"找下一任"效率
偏向锁,是优化了"分手"的效率
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.
因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.
也就是所谓的 "自适应
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex .
编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除
什么是 “锁消除”
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加
锁解锁操作是没有必要的, 白白浪费了一些资源开销.
锁消除,这个过程是编译过程触发的
偏向锁,实在运行过程中触发的
锁的粒度:synchronized里头,代码越多,锁的粒度越粗;代码越少,锁的粒度越细
粒度细时,能够并发执行的逻辑更多,更有利于充分利用多核cpu资源
但是,如果粒度细的锁,被反复进行加锁解锁,可能实际效果还不如粒度粗的锁(设计反复的锁竞争)
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化
实际开发过程中,使用细粒度锁,是期望释放锁的时候其他线程能使用锁
但实际可能并没有其他线程来抢占这个锁, 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁
这也是一种创建线程的方式
适合于想让某个线程执行一个逻辑,并且返回结果的时候
相比之下,Runnale不关注结果
Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序员借助多线程的方式计算结果.
代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 不使用 Callable 版本
static class Result {
public int sum = 0;
public Object lock = new Object();
}
public static void main(String[] args) throws InterruptedException {
Result result = new Result();
Thread t = new Thread() {
@Override
public void run() {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
synchronized (result.lock) {
result.sum = sum;
result.lock.notify();
}
}
};
t.start();
synchronized (result.lock) {
while (result.sum == 0) {
result.lock.wait();
}
System.out.println(result.sum);
}
}
可以看到, 上述代码需要一个辅助类 Result, 还需要使用一系列的加锁和 wait notify 操作, 代码复杂, 容易出错.
代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 使用 Callable 版本
public class Demo31 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer> () {
@Override
public Integer call() throws Exception {//call是callable的核心方法,返回值是Integer
int sum = 0;
for (int i = 0; i <=1000; i++) {
sum+=i;
}
return sum;
}
};
//把任务放到线程中去执行
FutureTask<Integer> futureTask = new FutureTask<> ( callable );
Thread t = new Thread (futureTask);
t.start ();
//此时的get 就能获取到callable里面返回值的结果
//由于线程是并发执行的,执行到主线程的get的时候,t线程可能还没完成执行
//没执行的话,get会阻塞
System.out.println (futureTask.get ());
}
}
理解 Callable
Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务,
Runnable 描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为
Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask 就可以负责这个等待结果出来的工作
理解 FutureTask
想象去吃麻辣烫. 当餐点好后, 后厨就开始做了. 同时前台会给你一张 “小票” . 这个小票就是
FutureTask. 后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没
1.继承Thread类,重写run(创建单独的类,也可以匿名内部类)
2.实现Runnable接口,重写run(创建单独的类,也可以匿名内部类)
3.实现Callable接口,重写run(创建单独的类,也可以匿名内部类)
4.使用lambda表达式
5.ThreadFactory线程工厂
6.线程池
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全
ReentrantLock 的用法:
ReentrantLock lock = new ReentrantLock();
-----------------------------------------
lock.lock();
try {
// working
} finally {
lock.unlock()
}
ReentrantLock 和 synchronized 的区别:
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个
AtomicBoolean
AtomicInteger
AtomicIntegerArray
AtomicLong
AtomicReference
AtomicStampedReference
以 AtomicInteger 举例,常见方法有
addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i–;
incrementAndGet(); ++i;
getAndIncrement(); i++
虽然创建销毁线程比创建销毁进程更轻量, 但是在频繁创建销毁线程的时候还是会比较低效
线程池就是为了解决这个问题. 如果某个线程不再使用了, 并不是真正把线程释放, 而是放到一个 "池子"中, 下次如果需要用到线程就直接从池子中取, 不必通过系统来创建了.
代码示例:
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
Executors 创建线程池的几种方式
Executors 本质上是 ThreadPoolExecutor 类的封装
ThreadPoolExecutor 提供了更多的可选参数, 可以进一步细化线程池行为的设定
理解 ThreadPoolExecutor 构造方法的参数
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.
corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).
keepAliveTime: 临时工允许的空闲时间.
unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
workQueue: 传递任务的阻塞队列
threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
AbortPolicy(): 超过负荷, 直接抛出异常.
CallerRunsPolicy(): 调用者负责处理
DiscardOldestPolicy(): 丢弃队列中最老的任务.
DiscardPolicy(): 丢弃新来的任务.
信号量,就是一个计数器,用来表示可用资源的个数
理解信号量
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)
当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.
这里的阻塞等待有种锁的感觉
锁,本质上就是属于一种特殊的信号量
锁就是可用资源为1的信号量(二元信号量)
加锁操作,P操作,1->0;
解锁操作,V操作,0->1;
Semaphore的PV操作中的加减计数器操作都是原子的,可以在多线程环境下直接使用
代码示例
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
System.out.println("申请资源");
semaphore.acquire();
System.out.println("我获取到资源了");
Thread.sleep(1000);
System.out.println("我释放资源了");
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 20; i++) {
Thread t = new Thread(runnable);
t.start();
}
CountDownLatch主要适用于,多个线程完成一些列任务的时候,用来衡量任务的进度是否完成
比如把一个大的任务,拆分成多个小的任务,让这些任务并发执行,同时等待 N 个任务执行结束
就可以用CountDownLatch来判定说当前这些任务是否全部完成了
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch =new CountDownLatch ( 10 );
for (int i = 0; i < 10; i++) {
int id = i;
Thread t = new Thread (()->{
System.out.println ("thread"+id);
//通知当前任务执行完毕了
countDownLatch.countDown ();
});
t.start ();
}
countDownLatch.await ();
System.out.println ("所有的任务都完成了");
}
synchronized,ReentranLock,Semaphore等都用于线程同步
以 juc 的 ReentrantLock 为例
- synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活
- synchronized申请锁失败会死等,ReentrantLock可以通过trylock的方式等待一段时间就放弃
synchronized是非公平锁,ReentrantLock默认是非公平锁,可以通过构造方法出入一个true开启公平锁模式
synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的
线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.
3)信号量听说过么?之前都用在过哪些场景下?
信号量,用来表示"可用资源个数",本质上是一个计数器
使用信号量可以实现"共享锁",比如某个资源允许3个线程同时使用,那么就可以使用P操作作为加锁,V操作作为解锁,前三个线程的P操作都能顺利返回,后续线程再进行P操作就会阻塞等待
知道前面的线程执行V操作
原来的集合类, 大部分都不是线程安全的.
Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的
1)自己使用同步机制(synchronized 或者 ReentrantLock)
2)Collections.synchronizedList(new ArrayList)
这个东西会返回一个新的对象,这个新的对象就相当于给ArrayList套了一层壳
这层壳就是在方法上直接使用synchronized的
3)使用CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器
所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:
在读多写少的场景下,性能很高,不需要锁竞争
缺点:
1.占用内存较多
2.写时的数据不能被第一时间读取到
3.当容器较大时,复制一份所消耗的资源可能比加锁更多
1)ArrayBlockingQueue
基于数组实现的阻塞队列
2)LinkedBlockingQueue
基于链表实现的阻塞队列
PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
TransferQueue
最多只包含一个元素的阻塞队列
HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用:
1)Hashtable
只是简单的把关键方法加上了 synchronized 关键字
public synchronized V put(K key,V value){
}
public synchronized V get(Object key){
}
这相当于直接针对 Hashtable 对象本身加锁
2)ConcurrentHashMap
相比于Hashtable做出一系列的改进和优化. 以 Java1.8 为例