一. ThreadLocal 的原理
ThreadLocal 相当于一个容器, 用于存放每个线程的局部变量。ThreadLocal 实例通常来说都是 private static 类型的。ThreadLocal 可以 给一个初始值, 而每个线程都会获得这个初始化值的一个副本, 这样才能保证 不同的线程都有一份拷贝。
一般情况下, 通过 ThreadLocal.set() 到线程中的对象是该线程自己使用 的对象, 其他线程是访问不到的, 各个线程中访问的是不同的对象。如果 ThreadLocal.set()进去的东西本来就是多个线程共享的同一个对象, 那么多个 线程的 ThreadLocal.get()取得的还是这个共享对象本身, 还是有并发访问问 题。
向 ThreadLocal 中 set 的变量是由 Thread 线程对象自身保存的, 当用户 调 用 ThreadLocal 对象的 set(Object o) 时 , 该方法则通过 Thread.currentThread() 获取当前线程, 将变量存入线程中的 ThreadLocalMap 类的对象内,Map 中元素的键为当前的 threadlocal 对象, 而值对应线程的变量副本。
public T get() {Thread t = Thread.currentThread(); // 每个 Thread 对象内都保存一个 ThreadLocalMap 对象。ThreadLocalMap map = getMap(t); //map 中 元 素 的 键 为 共 用 的
threadlocal 对象, 而值为对应线程的变量副本。if (map != null)
return (T)map.get(this);
}
T value = initialValue();
createMap(t, value);
return value;
}
public void set(T value) {Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {return t.threadLocals;}
void createMap(Thread t, T firstValue) {t.threadLocals = new ThreadLocalMap(this, firstValue);
}
二. Collections.synchronizedXX 方法的原理。
public E get(int index) {synchronized (mutex) {return list.get(index);
}
}
public E set(int index, E element) {synchronized (mutex) {return list.set(index,element);
}
}
public void add(int index, E element) {synchronized (mutex) {list.add(index, element);
}
}
public E remove(int index) {synchronized (mutex) {return list.remove(index);
}
}
在返回的列表上进行迭代时, 用户必须手工在返回的列表上进行同步:
List list = Collections.synchronizedList(new ArrayList());
...
synchronized(list) {Iterator i = list.iterator();
// Must be in synchronized block
while (i.hasNext())
foo(i.next());
}
三. 如何在两个线程间共享数据?
1. 每个线程执行的代码相同
若每个线程执行的代码相同, 共享数据就比较方便。可以使用同一个 Runnable 对象, 这个 Runnable 对象中就有那个共享数据。
public class MultiThreadShareData1 {public static void main(String[] args) {SaleTickets sale = new SaleTickets();
new Thread(sale).start();
new Thread(sale).start();}
}
class SaleTickets implements Runnable {
public int allTicketCount = 20;
public void run() {while (allTicketCount > 0) {sale();
}
}
public synchronized void sale() {System.out.println("剩下" + allTicketCount);
allTicketCount--;
}
}
2. 每个线程执行的代码不相同
如果每个线程执行的代码不同, 这时候需要用不同的 Runnable 对象, 将需要共享的 数据封装成一个对象, 将该对象传给执行不同代码的 Runnable 对象。
三. 简述 JAVA 的内存模型。
区别于“JVM 的内存模型”。
Java 内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内 存), 每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量 的所有操作都必须在工作内存中进行, 而不能直接对主存进行操作, 并且每个 线程不能访问其他线程的工作内存。
Java 内 存 模 型 的 Volatile 关 键 字 和 原 子 性、可 见 性、有 序 性 和 happens-before 关系等。
1. Volatile 关键字 解析见上面。
2. 要想并发程序正确地执行, 必须要保证原子性、可见性以及有序性。只要有一个没有被保证, 就有可能会导致程序运行不正确。
①. 原子性 :即一个操作或者多个操作 要么全部执行并且执行的过程不会被任 何因素打断, 要么就都不执行。可以通过 Synchronized 和 Lock 实现“原子性”。
例题: 请分析以下哪些操作是原子性操作。
x = 10;
// 语句 1
y = x;
// 语句 2
x++;
// 语句 3
x = x + 1;
// 语句 4
特别注意, 在 java 中, 只有对除 long 和 double 外的基本类型进行简 单的赋值 (如 int a=1) 或读取操作, 才是原子的。只要给 long 或 double 加上 volatile, 操作就是原子的了。
- 语句 1 是原子性操作, 其他三个语句都不是原子性操作。
- 语句 2 实际上包含 2 个操作, 它先要去读取 x 的值, 再将 x 的值写入工作 内存, 虽然读取 x 的值以及将 x 的值写入工作内存这 2 个操作都是原子性操作, 但是合起来就不是原子性操作了。
- 同样的,x++ 和 x = x+1 包括 3 个操作: 读取 x 的值, 进行加 1 操作, 写入新的值。
2. 可见性:是指当多个线程访问同一个变量时, 一个线程修改了这个变量的值, 其他线 程能够立即看得到修改的值。
通过 Synchronized 和 Lock 和 volatile 实现“可见性”。
3. 有序性:即程序执行的顺序按照代码的先后顺序执行。
我的理解就是一段程序代码的执行在单个线程中看起来是有序的。这个应该是程序看 起来执行的顺序是按照代码顺序执行的, 因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序, 但是最终执行的结果是与程序顺序执行的结果一致的, 它只会对不存在 数据依赖性的指令进行重排序。因此, 在单个线程中, 程序执行看起来是有序执行的, 这 一点要注意理解。事实上, 这个规则是用来保证程序在单线程中执行结果的正确性, 但无 法保证程序在多线程中执行的正确性。
3.happens-before 原则(先行发生原则):
- 程序次序规则: 一个线程内, 按照代码顺序, 书写在前面的操作先行发生于书写在 后面的操作
- 锁定规则: 一个 unLock 操作先行发生于后面对同一个锁的 lock 操作
- volatile 变量规则: 对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则: 如果操作 A 先行发生于操作 B, 而操作 B 又先行发生于操作 C, 则可以 得出操作 A 先行发生于操作 C
- 线程启动规则:Thread 对象的 start()方法先行发生于此线程的每个一个动作
- 线程中断规则: 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测 到中断事件的发生
- 线程终结规则: 线程中所有的操作都先行发生于线程的终止检测, 我们可以通过 T hread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则: 一个对象的初始化完成先行发生于他的 finalize()方法的开始
四. Java 中的同步容器类和缺陷。
在 Java 中, 同步容器主要包括 2 类:
- Vector、HashTable。
- Collections 类中提供的静态工厂方法创建的类。Collections.synchronizedXXX()。
缺陷:
①. 性能问题:在有多个线程进行访问时, 如果多个线程都只是进行读取操作, 那么每个 时刻就只能有一个线程进行读取, 其他线程便只能等待, 这些线程必须竞争同 一把锁。
②. ConcurrentModificationException 异常:在对 Vector 等容器进行迭代修改时, 会报 ConcurrentModificationException 异常。但是在并发容器中 (如 ConcurrentHashMap,CopyOnWriteArrayList 等) 不会出现这个问 题。
五. Java 中堆和栈有什么不同?
栈是一块和线程紧密相关的内存区域。每个线程都有自己的栈内存, 用于 存储本地变量, 方法参数和栈调用, 一个线程中存储的变量对其它线程是不可 见的。而堆是所有线程共享的一片公用内存区域。对象都在堆里创建, 为了提 升效率线程会从堆中弄一个缓存到自己的栈, 如果多个线程使用该变量就可能 引发问题, 这时 volatile 变量就可以发挥作用了, 它要求线程从主存中读取变 量的值。
六. 网站的高并发, 大流量访问怎么解决?
1.HTML 页面静态化
访问频率较高但内容变动较小, 使用网站 HTML 静态化方案来优化访问速度。将社区 内的帖子、文章进行实时的静态化, 有更新的时候再重新静态化也是大量使用的策略。
优势:
- 减轻服务器负担。
- 加快页面打开速度, 静态页面无需访问数据库, 打开速度较动态页面有明显提高;
- 很多搜索引擎都会优先收录静态页面, 不仅被收录的快, 还收录的全, 容易被搜 索引擎找到;
- HTML 静态页面不会受程序相关漏洞的影响, 减少攻击 , 提高安全性。
2. 图片服务器和应用服务器相分离
现在很多的网站上都会用到大量的图片, 而图片是网页传输中占主要的数据量, 也是影 响网站性能的主要因素。因此很多网站都会将图片存储从网站中分离出来, 另外架构一个 或多个服务器来存储图片, 将图片放到一个虚拟目录中, 而网页上的图片都用一个 URL 地 址来指向这些服务器上的图片的地址, 这样的话网站的性能就明显提高了。
优势:
- 分担 Web 服务器的 I/O 负载 - 将耗费资源的图片服务分离出来, 提高服务器的性能 和稳定性。
- 能够专门对图片服务器进行优化 - 为图片服务设置有针对性的缓存方案, 减少带宽 成本, 提高访问速度。
- 提高网站的可扩展性 - 通过增加图片服务器, 提高图片吞吐能力。
3. 数据库
见“数据库部分的 — 如果有一个特别大的访问量到数据库上, 怎么做优化?”。
4. 缓存
尽量使用缓存, 包括用户缓存, 信息缓存等, 多花点内存来做缓存, 可以大量减少与 数据库的交互, 提高性能。
假如我们能减少数据库频繁的访问, 那对系统肯定大大有利的。比如一个电子商务系 统的商品搜索, 如果某个关键字的商品经常被搜, 那就可以考虑这部分商品列表存放到缓 存(内存中去), 这样不用每次访问数据库, 性能大大增加。
5. 镜像
镜像是冗余的一种类型, 一个磁盘上的数据在另一个磁盘上存在一个完全相同的副本 即为镜像。
6. 负载均衡
在网站高并发访问的场景下, 使用负载均衡技术 (负载均衡服务器) 为一个应用构建 一个由多台服务器组成的服务器集群, 将并发访问请求分发到多台服务器上处理, 避免单 一服务器因负载压力过大而响应缓慢, 使用户请求具有更好的响应延迟特性。
7. 并发控制
加锁, 如乐观锁和悲观锁。
8. 消息队列
通过 mq 一个一个排队方式, 跟 12306 一样。
七. 可扩展到任何高并发网站要考虑的并发读写问题
订票系统, 某车次只有一张火车票, 假定有 1w 个人同 时打开 12306 网站来订票, 如何解决并发问题?(可扩展 到任何高并发网站要考虑的并发读写问题)。
不但要保证 1w 个人能同时看到有票(数据的可读性), 还要保证最终只能 由一个人买到票(数据的排他性)。
使用数据库层面的并发访问控制机制。采用乐观锁即可解决此问题。乐观 锁意思是不锁定表的情况下, 利用业务的控制来解决并发问题, 这样既保证数 据的并发可读性, 又保证保存数据的排他性, 保证性能的同时解决了并发带来 的脏数据问题。hibernate 中实现乐观锁。
银行两操作员同时操作同一账户就是典型的例子。比如 A、B 操作员同 时读取一余额为 1000 元的账户,A 操作员为该账户增加 100 元,B 操作员同时 为该账户减去 50 元, A 先提交, B 后提交。最后实际账户余额为 1000-50=950 元, 但本该为 1000+100-50=1050。这就是典型的并发问题。如何解决? 可以 用锁。
八. 编程实现一个最大元素为 100 的阻塞队列。
Lock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();
Object[] items = new Object[100];
int putptr, takeptr, count;
public void put(Object x) throws InterruptedException {lock.lock();
try {while (count == items.length)
notFull.await();
items[putptr] = x;
if (++putptr == items.length)
putptr = 0;
++count;
notEmpty.signal();}
finally {lock.unlock();
}
}
public Object take() throws InterruptedException {lock.lock();
try {while (count == 0)
notEmpty.await();
Object x = items[takeptr];
if (++takeptr == items.length)
takeptr = 0;
--count;
notFull.signal();
return x;
}
finally {lock.unlock();
}
}
九. 设计一个双缓冲阻塞队列, 写代码。
在服务器开发中, 通常的做法是把逻辑处理线程和 I/O 处理线程分离。
- 逻辑处理线程:对接收的包进行逻辑处理。
- I/0 处理线程:网络数据的发送和接收, 连接的建立和维护。
通常逻辑处理线程和 I/O 处理线程是通过数据队列来交换数据, 就是生产 者 – 消费者模型。
这个数据队列是多个线程在共享, 每次访问都需要加锁, 因此如何减少互 斥 / 同步的开销就显得尤为重要。解决方案: 双缓冲队列。
两个队列, 将读写分离, 一个给逻辑线程读, 一个给 IO 线程用来写, 当 逻辑线程读完队列后会将自己的队列与 IO 线程的队列相调换。这里需要加锁的 地方有两个, 一个是 IO 线程每次写队列时都要加锁, 另一个是逻辑线程在调换 队列时也需要加锁, 但逻辑线程在读队列时是不需要加锁的。如果是一块缓冲 区, 读、写操作是不分离的, 双缓冲区起码节省了单缓冲区时读部分操作互斥 / 同步的开销。本质是采用空间换时间的优化思路。
十. Java 中的队列都有哪些, 有什么区别。
- 队列都实现了 Queue 接口。
- 阻塞队列和非阻塞队列。
- 阻塞队列: 见上面的讲解。
- 非阻塞队列:LinkedList,PriorityQueue。
写在最后
作为一名 Java 程序员,想进 BAT 只学多线程还远远不够!
想进 BAT,像 Kafka、Mysql、Tomcat、Docker、Spring、MyBatis、Nginx、Netty、Dubbo、Redis、Netty、Spring cloud、分布式、高并发、性能调优、微服务等架构技术你都要学习!
当然以上技术能够掌握百分之八九十的话,进阿里 P8 还是没什么大问题的!
那么笔者也针对以上技术整理了一份完整的面试资料(详见下图)
需要的朋友,点击下方传送门即可免费领取!
传送门
以下是部分面试题截图