面试刷题网站:xiaolincoding.com
大家好,我是小林。
我又来给大家查漏补缺了!之前跟大家盘点26届校招薪资的时候,有读者在评论区留言说想看海康威视的薪资情况。
看到留言后,我就立马赶工收集了一波26届海康威视开发岗位的薪资数据。
先说一下海康威视的年终奖情况,一般是2-4个月。我这边取个中间值,按照15薪来计算年总包,这样大家对比起来也比较直观。
我把收集到的不同offer档次做了个划分,大家可以看一下:
绿色部分应该是普通offer,月薪在14k到15k左右
橙色是SP offer,月薪16k到18k
红色是SSP offer,月薪19k以上了
怎么说呢,这个薪资说体面是完全没问题的。谈不上特别高,但也绝对不低,就是那种15k到20k的范畴,属于中厂的薪资待遇水平吧。
然后我也对比了一下去年的数据。说实话,基本没啥变化,薪资范围还是一样的。下面这几个就是去年25届海康威视开发岗的薪资情况:
除了薪资之外,还有一点值得说一下,海康威视是六险一金,而且公积金是拉满的,交12%,这一点还是挺不错的。
那可能有同学要问了,海康威视的面试难度如何呢?
看下来的话,这场面试主要考察了JavaSE、排序算法、Redis、MySQL、Java并发、操作系统、Spring这些知识点。问到的知识点确实不少,但每个点基本就是问一下,没有在某个知识点上追问得特别深。
所以整体来说,难度不算太难,属于那种广度大于深度的面试风格。只要你基础扎实,各个知识点都有所了解,应对起来应该不会太吃力。
海康威视(后端开发一面)
1. 你是如何理解Java的面向对象特性?
我觉得Java的面向对象特性主要体现在封装、继承、多态这三个方面,这也是我平时写代码用得最多的。
先说封装吧。
封装的核心思想就是把数据和操作数据的方法绑定在一起,然后对外隐藏内部实现细节,只暴露必要的接口。比如我写一个用户类,会把用户的属性像姓名、年龄这些设置成private,然后提供public的getter和setter方法来访问。这样做的好处是我可以在setter里加校验逻辑,比如年龄不能是负数,保证数据的有效性。而且以后如果内部实现要改,只要接口不变,外部调用的代码就不用动。我之前重构过一个支付模块,因为一开始封装做得好,后来把金额的计算逻辑全改了,但对外的接口没变,调用方完全无感知。
继承的话,它让子类可以复用父类的代码,避免重复编写。
比如我做过一个商品系统,有实体商品、虚拟商品、服务类商品,它们都有一些共同的属性和行为,像商品名称、价格、库存管理这些。我就定义了一个BaseProduct父类,把公共的部分抽出来,然后不同类型的商品继承它,再实现各自特有的逻辑。实体商品要处理物流,虚拟商品要生成兑换码,各自重写相应的方法就行。不过继承要慎用,因为它的耦合度比较高,父类改了可能会影响所有子类。如果只是为了复用代码,有时候用组合会更好,这也是为什么说要优先使用组合而不是继承。
多态
我觉得是面向对象最精髓的部分。多态让同一个接口可以有不同的实现,调用时根据实际对象类型来决定执行哪个方法。我最常用的场景就是定义接口或者抽象类,然后有多个实现类。比如支付方式,我定义一个Payment接口,有pay方法,然后有AliPay、WeChatPay、UnionPay这些实现类。业务代码里只依赖Payment接口,传进来什么实现就调用什么的pay方法,根本不用管具体是哪种支付方式。这样新增支付渠道就特别简单,只要实现这个接口就行,不用改已有的代码。这也符合开闭原则,对扩展开放,对修改封闭。
2. 说一下你对泛型的了解?
泛型是 Java 编程语言中的一个重要特性,它允许类、接口和方法在定义时使用一个或多个类型参数,这些类型参数在使用时可以被指定为具体的类型。
泛型的主要目的是在编译时提供更强的类型检查,并且在编译后能够保留类型信息,避免了在运行时出现类型转换异常。
为什么需要泛型?
适用于多种数据类型执行相同的代码
private static int add(int a, int b) {
System.out.println(a + "+" + b + "=" + (a + b));
return a + b;
}
private static float add(float a, float b) {
System.out.println(a + "+" + b + "=" + (a + b));
return a + b;
}
private static double add(double a, double b) {
System.out.println(a + "+" + b + "=" + (a + b));
return a + b;
}
如果没有泛型,要实现不同类型的加法,每种类型都需要重载一个add方法;通过泛型,我们可以复用为一个方法:
private static <T extends Number> double add(T a, T b) {
System.out.println(a + "+" + b + "=" + (a.doubleValue() + b.doubleValue()));
return a.doubleValue() + b.doubleValue();
}
泛型中的类型在使用时指定,不需要强制类型转换
(类型安全,编译器会检查类型)
看下这个例子:
List list = new ArrayList();
list.add("xxString");
list.add(100d);
list.add(new Person());
我们在使用上述list中,list中的元素都是Object类型(无法约束其中的类型),所以在取出集合元素时需要人为的强制类型转化到具体的目标类型,且很容易出现java.lang.ClassCastException异常。
引入泛型,它将提供类型的约束,提供编译前的检查:
List<String> list = new ArrayList<String>();
// list中只能放String, 不能放其它类型的元素
3. java 集合中 map,list,set的底层数据结构是怎样的?
先说List吧。最常用的就是ArrayList和LinkedList。
ArrayList底层是数组,默认初始容量是10,当元素个数超过容量时会自动扩容,扩容大小是原来的1.5倍。因为是数组,所以根据索引查询特别快,时间复杂度是O(1),但插入和删除就慢了,因为要移动后面的元素,时间复杂度是O(n)。我平时如果是频繁查询的场景就用ArrayList。LinkedList底层是双向链表,每个节点都有前驱和后继指针。它的特点正好相反,插入删除快,只要改改指针就行,但查询慢,必须从头或者从尾遍历,时间复杂度是O(n)。不过实际工作中LinkedList用得比较少,因为就算是频繁插入删除,ArrayList在数据量不大的情况下性能也不差。
Set的话,最常用的是HashSet和TreeSet。
HashSet底层其实就是HashMap,它的元素存在HashMap的key里,value是一个固定的Object对象。所以HashSet的特性完全依赖HashMap,元素无序,不能重复,查询添加删除的时间复杂度都是O(1)。TreeSet底层是TreeMap,也就是红黑树,它的元素是有序的,按照自然顺序或者自定义比较器排序,查询添加删除的时间复杂度是O(log n)。如果我需要去重就用HashSet,需要去重还要排序就用TreeSet。LinkedHashSet比较特殊,它底层是HashMap加上一个双向链表,既能保证O(1)的性能,又能维护插入顺序。
Map是最复杂的。
HashMap在JDK1.8之前是数组加链表的结构,根据key的hashCode计算数组下标,如果发生hash冲突就用链表挂在数组元素后面。但链表太长查询就慢了,所以JDK1.8做了优化,当链表长度超过8并且数组长度大于64时,会把链表转成红黑树,这样即使冲突严重,查询效率也能保证O(log n)。HashMap默认容量是16,负载因子是0.75,也就是说当元素个数超过容量乘以0.75时就会扩容,每次扩容是原来的2倍。扩容的时候要重新计算hash,把元素放到新数组里,这个过程比较耗时。
HashMap是线程不安全的,如果要线程安全可以用ConcurrentHashMap。它在JDK1.7用的是分段锁,把整个数组分成多个Segment,每个Segment是一个小的HashMap,锁粒度比较细。但JDK1.8改了,不再用Segment了,直接用CAS加synchronized来控制并发。它对数组的每个桶位单独加锁,只有写同一个桶的时候才会冲突,并发性能比1.7更好。我之前做过压测,ConcurrentHashMap在高并发读写场景下性能确实比Collections.synchronizedMap强太多了。
TreeMap底层是红黑树,key是有序的,可以实现范围查询。我用过它做时间段的数据查询,因为红黑树天然支持范围操作。LinkedHashMap继承自HashMap,但它多维护了一个双向链表来保存插入顺序或者访问顺序。如果设置accessOrder为true,它就变成了一个LRU缓存的基础结构,每次访问元素都会把它移到链表尾部,这样链表头部就是最久未使用的元素。我实现本地缓存的时候就用过LinkedHashMap,重写removeEldestEntry方法,超过容量就自动删除最老的元素。
还有个Hashtable,它和HashMap类似,但它是线程安全的,方法都加了synchronized,不过性能比较差,而且不允许key或value为null,现在基本不用了。
总结一下:
- ArrayList是数组,查询快。LinkedList是链表,插入删除快但实际用得少。HashSet基于HashMap,无序去重。TreeSet基于红黑树,有序去重。HashMap是数组加链表或红黑树,高性能但线程不安全。ConcurrentHashMap用CAS和synchronized实现线程安全的高并发。TreeMap是红黑树,支持排序和范围查询。LinkedHashMap在HashMap基础上加了链表,能保持顺序或实现LRU。
选哪个主要看业务场景,是看重查询性能还是插入删除性能,是否需要排序,是否需要线程安全。
4. 常用的排序算法有哪些,及其时间复杂度
- 冒泡排序:通过相邻元素的比较和交换,每次将最大(或最小)的元素逐步“冒泡”到最后(或最前)。时间复杂度:最好情况下O(n),最坏情况下O(n^2),平均情况下O(n^2)。,空间复杂度:O(1)。
- 插入排序:将待排序元素逐个插入到已排序序列的合适位置,形成有序序列。时间复杂度:最好情况下O(n),最坏情况下O(n^2),平均情况下O(n^2),空间复杂度:O(1)。
- 选择排序(Selection Sort):通过不断选择未排序部分的最小(或最大)元素,并将其放置在已排序部分的末尾(或开头)。时间复杂度:最好情况下O(n^2),最坏情况下O(n^2),平均情况下O(n^2),空间复杂度:O(1)。
- 快速排序(Quick Sort):通过选择一个基准元素,将数组划分为两个子数组,使得左子数组的元素都小于(或等于)基准元素,右子数组的元素都大于(或等于)基准元素,然后对子数组进行递归排序。时间复杂度:最好情况下O(nlogn),最坏情况下O(n^2),平均情况下O(nlogn),空间复杂度:最好情况下O(logn),最坏情况下O(n)。
- 归并排序(Merge Sort):将数组不断分割为更小的子数组,然后将子数组进行合并,合并过程中进行排序。时间复杂度:最好情况下O(nlogn),最坏情况下O(nlogn),平均情况下O(nlogn)。空间复杂度:O(n)。
- 堆排序(Heap Sort):通过将待排序元素构建成一个最大堆(或最小堆),然后将堆顶元素与末尾元素交换,再重新调整堆,重复该过程直到排序完成。时间复杂度:最好情况下O(nlogn),最坏情况下O(nlogn),平均情况下O(nlogn)。空间复杂度:O(1)。
5. 谈一谈对redis的理解,高并发下会有什么问题?
Redis我理解它就是一个基于内存的高性能键值数据库,主要用来做缓存、分布式锁、消息队列这些场景。
它最大的特点就是快,因为数据都在内存里,单机能达到几万甚至十万级别的QPS。而且它支持丰富的数据类型,String、List、Hash、Set、ZSet这些基本够用了。我们项目里主要用它做缓存,减轻数据库压力,还有用它的ZSet做排行榜,用String加过期时间做Session存储。
但高并发场景下,Redis会遇到几个比较头疼的问题。
第一个是缓存穿透。就是查询一个根本不存在的数据,缓存里没有,数据库里也没有,但每次请求都会打到数据库,高并发下数据库就扛不住了。我们当时遇到过有人恶意攻击,不停地查询不存在的订单号。
解决办法主要有两种,一个是用布隆过滤器,在查缓存之前先过滤一遍,不存在的直接拦截掉。另一个是缓存空值,如果数据库查不到就在Redis里存个null,设置一个比较短的过期时间,这样短时间内相同请求就不会再打到数据库了。
第二个是缓存击穿,也叫热点Key失效。就是一个访问量特别大的Key突然过期了,这一瞬间大量请求都打到数据库上,把数据库搞挂了。我们线上遇到过一次,首页的热门商品缓存到期了,正好是秒杀高峰期,数据库直接被打爆。
解决方案是对热点Key设置永不过期,或者用互斥锁,当缓存失效时,不是所有请求都去查数据库,而是只让一个请求去查,查到后更新缓存,其他请求等待或者重试。我们用的是加分布式锁的方式,第一个请求拿到锁去查库,其他请求等待,效果还不错。
第三个是缓存雪崩,就是大量的Key在同一时间集中失效,或者Redis整个宕机了,所有请求瞬间打到数据库,数据库直接崩溃。这个比击穿更严重,因为不是一个Key而是一批Key。
我们的做法是给过期时间加随机值,比如基础时间是1小时,然后随机加0到5分钟,这样Key就不会集中失效了。
还有就是做Redis高可用,用主从加哨兵模式,保证Redis挂了能快速切换。我们还加了本地缓存作为兜底,Redis挂了至少本地缓存能顶一会儿,不至于流量全打到数据库。
第四个是缓存和数据库的一致性问题。这个在高并发下特别容易出问题。经典的就是先删缓存再更新数据库,或者先更新数据库再删缓存,但无论哪种都可能出现不一致。
我们采用的是延迟双删策略,先删缓存,再更新数据库,然后休眠几百毫秒再删一次缓存,这样可以把更新期间脏读的数据也清掉。但说实话,强一致性很难做到,我们对一致性要求不那么高的场景就允许短暂的不一致,设置比较短的过期时间让它自然过期。
6. springboot和springmvc的差异是什么?设计理念的区别?
首先要明确一点,SpringBoot和SpringMVC不是同一层面的东西。
SpringMVC是Spring框架里的一个Web层框架,专门处理HTTP请求,做MVC架构的事情。而SpringBoot是一个快速开发脚手架,底层还是用的Spring那一套,包括SpringMVC。所以SpringBoot是包含SpringMVC的,只是让使用变得更简单了。
最直观的区别就是配置复杂度。以前用SpringMVC开发,得配置web.xml、spring-mvc.xml、applicationContext.xml这一堆XML文件,配置DispatcherServlet、视图解析器、数据源、事务管理器这些,然后打成war包部署到Tomcat里,整个流程特别繁琐。我刚开始学的时候,光搭一个能跑的项目就得折腾大半天。
SpringBoot就完全不一样了,核心理念是约定大于配置。引入spring-boot-starter-web这个依赖,加个@SpringBootApplication注解,写个main方法直接运行就能启动,内嵌Tomcat,连部署都不用了。它的自动配置机制会根据你的依赖自动判断要配置什么,比如引入了MySQL驱动和MyBatis的starter,它就自动配置好DataSource和SqlSessionFactory,我只需要在application.yml里写几行数据库连接信息就行了。
从设计理念上讲,SpringMVC强调灵活性和可配置性,给你完整的MVC架构,但怎么配置、怎么组装由开发者决定,自由度高但配置复杂。SpringBoot的理念是简化开发、快速上手,做了大量默认配置,把最佳实践内置进去,开发者专注写业务代码就行。SpringMVC是框架思维,定义规范和接口你自己用。SpringBoot是平台思维,集成了监控、健康检查、配置管理这些企业级特性。
SpringBoot还有Starter依赖管理,把常用技术栈封装成starter,引入就自动搞定依赖和配置,不用担心版本冲突。还内置了Actuator监控模块,提供健康检查、指标收集这些功能,特别适合微服务和容器化部署。
总结就是,SpringMVC是纯粹的Web框架,解决MVC分层问题。SpringBoot是开发平台,解决快速搭建、简化配置、方便部署的问题。现在基本都是用SpringBoot开发,它底层调用SpringMVC处理Web请求,但开发体验好太多
7. springboot常用注解有哪些?
Bean 相关:
@Component:将一个类标识为 Spring 组件(Bean),可以被 Spring 容器自动检测和注册。通用注解,适用于任何层次的组件。
@ComponentScan:自动扫描指定包及其子包中的 Spring 组件。
@Controller:标识控制层组件,实际上是 @Component 的一个特化,用于表示 Web 控制器。处理 HTTP 请求并返回视图或响应数据。
@RestController:是 @Controller 和 @ResponseBody 的结合,返回的对象会自动序列化为 JSON 或 XML,并写入 HTTP 响应体中。
@Repository:标识持久层组件(DAO 层),实际上是 @Component 的一个特化,用于表示数据访问组件。常用于与数据库交互。
@Bean:方法注解,用于修饰方法,主要功能是将修饰方法的返回对象添加到 Spring 容器中,使得其他组件可以通过依赖注入的方式使用这个对象。
依赖注入:
@Autowired:用于自动注入依赖对象,Spring 框架提供的注解。
@Resource:按名称自动注入依赖对象(也可以按类型,但默认按名称),JDK 提供注解。
@Qualifier:与 @Autowired 一起使用,用于指定要注入的 Bean 的名称。当存在多个相同类型的 Bean 时,可以使用 @Qualifier 来指定注入哪一个。
读取配置:
@Value:用于注入属性值,通常从配置文件中获取。标注在字段上,并指定属性值的来源(如配置文件中的某个属性)。
@ConfigurationProperties:用于将配置属性绑定到一个实体类上。通常用于从配置文件中读取属性值并绑定到类的字段上。
Web相关:
@RequestMapping:用于映射 HTTP 请求到处理方法上,支持 GET、POST、PUT、DELETE 等请求方法。可以标注在类或方法上。标注在类上时,表示类中的所有响应请求的方法都是以该类路径为父路径。
@GetMapping、@PostMapping、@PutMapping、@DeleteMapping:分别用于映射 HTTP GET、POST、PUT、DELETE 请求到处理方法上。它们是 @RequestMapping 的特化,分别对应不同的 HTTP 请求方法。
其他常用注解:
@Transactional:声明事务管理。标注在类或方法上,指定事务的传播行为、隔离级别等。
@Scheduled:声明一个方法需要定时执行。标注在方法上,并指定定时执行的规则(如每隔一定时间执行一次)。
8. MySQL常用的sql优化思路是什么?
分析查询语句:使用EXPLAIN命令分析SQL执行计划,找出慢查询的原因,比如是否使用了全表扫描,是否存在索引未被利用的情况等,并根据相应情况对索引进行适当修改。
创建或优化索引:根据查询条件创建合适的索引,特别是经常用于WHERE子句的字段、Orderby 排序的字段、Join 连表查询的字典、 group by的字段,并且如果查询中经常涉及多个字段,考虑创建联合索引,使用联合索引要符合最左匹配原则,不然会索引失效
避免索引失效:比如不要用左模糊匹配、函数计算、表达式计算等等。
查询优化:避免使用SELECT *,只查询真正需要的列;使用覆盖索引,即索引包含所有查询的字段;联表查询最好要以小表驱动大表,并且被驱动表的字段要有索引,当然最好通过冗余字段的设计,避免联表查询。
分页优化:针对 limit n,y 深分页的查询优化,可以把Limit查询转换成某个位置的查询:select * from tb_sku where id>20000 limit 10,该方案适用于主键自增的表。
读写分离:搭建主从架构, 利用数据库的读写分离,Web服务器在写数据的时候,访问主数据库(master),主数据库通过主从复制将数据更新同步到从数据库(slave),这样当Web服务器读数据的时候,就可以通过从数据库获得数据。这一方案使得在大量读操作的Web应用可以轻松地读取数据,而主数据库也只会承受少量的写入操作,还可以实现数据热备份,可谓是一举两得。
优化数据库表:如果单表的数据超过了千万级别,考虑是否需要将大表拆分为小表,减轻单个表的查询压力。也可以将字段多的表分解成多个表,有些字段使用频率高,有些低,数据量大时,会由于使用频率低的存在而变慢,可以考虑分开。
使用缓存技术:引入缓存层,如Redis,存储热点数据和频繁查询的结果,但是要考虑缓存一致性的问题,对于读请求会选择旁路缓存策略,对于写请求会选择先更新 db,再删除缓存的策略。
9. 进程和线程的区别是什么?
本质区别:进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位
在开销方面:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小
稳定性方面:进程中某个线程如果崩溃了,可能会导致整个进程都崩溃。而进程中的子进程崩溃,并不会影响其他进程。
内存分配方面:系统在运行的时候会为每个进程分配不同的内存空间;而对线程而言,除了CPU外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源),线程组之间只能共享资源
包含关系:没有线程的进程可以看做是单线程的,如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线
10. 线程间通信方式有哪些?
1、Object 类的 wait()、notify() 和 notifyAll() 方法。这是 Java 中最基础的线程间通信方式,基于对象的监视器(锁)机制。
wait():使当前线程进入等待状态,直到其他线程调用该对象的 notify() 或 notifyAll() 方法。
notify():唤醒在此对象监视器上等待的单个线程。
notifyAll():唤醒在此对象监视器上等待的所有线程。
class SharedObject {
public synchronized void consumerMethod() throws InterruptedException {
while (/* 条件不满足 */) {
wait();
}
// 执行相应操作
}
public synchronized void producerMethod() {
// 执行相应操作
notify(); // 或者 notifyAll()
}
}
2、Lock 和 Condition 接口。Lock 接口提供了比 synchronized 更灵活的锁机制,Condition 接口则配合 Lock 实现线程间的等待 / 通知机制。
await():使当前线程进入等待状态,直到被其他线程唤醒。
signal():唤醒一个等待在该 Condition 上的线程。
signalAll():唤醒所有等待在该 Condition 上的线程。
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class SharedResource {
privatefinal Lock lock = new ReentrantLock();
privatefinal Condition condition = lock.newCondition();
public void consumer() throws InterruptedException {
lock.lock();
try {
while (/* 条件不满足 */) {
condition.await();
}
// 执行相应操作
} finally {
lock.unlock();
}
}
public void producer() {
lock.lock();
try {
// 执行相应操作
condition.signal(); // 或者 signalAll()
} finally {
lock.unlock();
}
}
}
3、volatile 关键字。volatile 关键字用于保证变量的可见性,即当一个变量被声明为 volatile 时,它会保证对该变量的写操作会立即刷新到主内存中,而读操作会从主内存中读取最新的值。
class VolatileExample {
privatevolatileboolean flag = false;
public void writer() {
flag = true;
}
public void reader() {
while (!flag) {
// 等待
}
// 执行相应操作
}
}
4、CountDownLatch。CountDownLatch 是一个同步辅助类,它允许一个或多个线程等待其他线程完成操作。
CountDownLatch(int count):构造函数,指定需要等待的线程数量。
countDown():减少计数器的值。
await():使当前线程等待,直到计数器的值为 0。
import java.util.concurrent.CountDownLatch;
publicclass CountDownLatchExample {
public static void main(String[] args) throws InterruptedException {
int threadCount = 3;
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
try {
// 执行任务
System.out.println(Thread.currentThread().getName() + " 完成任务");
} finally {
latch.countDown();
}
}).start();
}
latch.await();
System.out.println("所有线程任务完成");
}
}
5、CyclicBarrier。CyclicBarrier 是一个同步辅助类,它允许一组线程相互等待,直到所有线程都到达某个公共屏障点。
CyclicBarrier(int parties, Runnable barrierAction):构造函数,指定参与的线程数量和所有线程到达屏障点后要执行的操作。
await():使当前线程等待,直到所有线程都到达屏障点。
import java.util.concurrent.CyclicBarrier;
publicclass CyclicBarrierExample {
public static void main(String[] args) {
int threadCount = 3;
CyclicBarrier barrier = new CyclicBarrier(threadCount, () -> {
System.out.println("所有线程都到达屏障点");
});
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
try {
// 执行任务
System.out.println(Thread.currentThread().getName() + " 到达屏障点");
barrier.await();
// 继续执行后续任务
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
}
6、Semaphore。Semaphore 是一个计数信号量,它可以控制同时访问特定资源的线程数量。
Semaphore(int permits):构造函数,指定信号量的初始许可数量。
acquire():获取一个许可,如果没有可用许可则阻塞。
release():释放一个许可。
import java.util.concurrent.Semaphore;
publicclass SemaphoreExample {
public static void main(String[] args) {
int permitCount = 2;
Semaphore semaphore = new Semaphore(permitCount);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
semaphore.acquire();
System.out.println(Thread.currentThread().getName() + " 获得许可");
// 执行任务
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
System.out.println(Thread.currentThread().getName() + " 释放许可");
}
}).start();
}
}
}
11. 多线程下, Java资源一致性如何保证?
在多线程环境下保证资源一致性,Java主要提供了几种机制,都是围绕可见性、原子性、有序性这三个核心问题来解决的。
最常用的就是synchronized关键字,它是一种互斥锁机制。当一个线程进入synchronized代码块时,会先清空工作内存,从主内存重新读取最新值,执行完后再把修改刷回主内存,这样就保证了可见性。同时synchronized是排他的,同一时刻只有一个线程能执行,所以也保证了原子性和有序性。比如说:
public class Counter {
private int count = 0;
public synchronized void increment() {
count++; // 这个操作是原子的
}
public synchronized int getCount() {
return count;
}
}
它的缺点是性能开销比较大,因为涉及线程阻塞和上下文切换。
第二种是volatile关键字,它主要解决可见性和有序性问题。当一个变量被volatile修饰后,任何线程对它的写操作都会立即刷新到主内存,其他线程读取时会强制从主内存读取最新值,不会用缓存的副本。volatile底层是通过内存屏障实现的,在写操作前后插入StoreStore和StoreLoad屏障,读操作后插入LoadLoad和LoadStore屏障,这样就禁止了指令重排,保证了有序性。比如:
public class Task {
privatevolatileboolean running = true;
public void stop() {
running = false; // 写操作立即对其他线程可见
}
public void run() {
while (running) { // 能及时看到running的变化
// 执行任务
}
}
}
但是volatile不能保证原子性,像count++这种复合操作还是会有线程安全问题。
第三种是Lock接口,比如ReentrantLock。它比synchronized更灵活,支持公平锁、非公平锁,还能响应中断、设置超时时间、尝试获取锁。它的原理是基于AQS实现的,通过CAS操作和一个FIFO队列来管理锁的获取和释放。比如:
public class Account {
privateint balance = 0;
private Lock lock = new ReentrantLock();
public void deposit(int amount) {
lock.lock();
try {
balance += amount;
} finally {
lock.unlock(); // 必须在finally里释放
}
}
public boolean tryDeposit(int amount) {
if (lock.tryLock()) { // 尝试获取锁,不阻塞
try {
balance += amount;
returntrue;
} finally {
lock.unlock();
}
}
returnfalse;
}
}
Lock需要手动加锁解锁,一定要在finally块里释放锁,不然可能造成死锁。
还有就是原子类,像AtomicInteger、AtomicLong这些。它们底层用的是CAS机制,也就是Compare And Swap,通过CPU的原子指令来保证操作的原子性,不需要加锁。CAS的原理就是比较当前值和期望值,如果相等就更新成新值,不相等就自旋重试。比如:
public class Counter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 原子操作,无需加锁
}
public int getCount() {
return count.get();
}
public void addTen() {
count.addAndGet(10); // 原子地加10
}
}
这种方式在低竞争情况下性能很好,但是高并发下会出现大量自旋失败,CPU开销会很高。而且CAS有个ABA问题,就是值从A变成B再变回A,CAS检测不出来,需要用AtomicStampedReference加版本号来解决。
如果是读多写少的场景,可以用读写锁,也就是ReentrantReadWriteLock。它把锁分成了读锁和写锁,读锁是共享的,多个线程可以同时读;写锁是独占的,写的时候不能有其他线程读或写。比如:
public class Cache {
private Map<String, Object> data = new HashMap<>();
private ReadWriteLock rwLock = new ReentrantReadWriteLock();
public Object get(String key) {
rwLock.readLock().lock();
try {
return data.get(key); // 多个线程可以同时读
} finally {
rwLock.readLock().unlock();
}
}
public void put(String key, Object value) {
rwLock.writeLock().lock();
try {
data.put(key, value); // 写的时候独占
} finally {
rwLock.writeLock().unlock();
}
}
}
这样读操作就可以并发执行,大大提高了性能。它的实现是把state一分为二,高16位存读锁计数,低16位存写锁重入数。还有个锁降级的特性,就是持有写锁时可以获取读锁,然后释放写锁,保证数据一致性,但不支持锁升级。
对于一些特殊场景,还有ThreadLocal,它为每个线程创建一个独立的变量副本,各用各的,从根本上避免了共享带来的线程安全问题。比如:
public class UserContext {
privatestatic ThreadLocal<User> userHolder = new ThreadLocal<>();
public static void setUser(User user) {
userHolder.set(user); // 每个线程存自己的user
}
public static User getUser() {
return userHolder.get(); // 获取当前线程的user
}
public static void clear() {
userHolder.remove(); // 用完必须清理
}
}
底层是Thread对象里有个ThreadLocalMap,以ThreadLocal为key存储数据。不过要注意内存泄漏问题,因为Entry的key是弱引用,但value是强引用,如果线程不结束,value就不会被回收,所以用完一定要手动调用remove方法。
总结一下就是,synchronized和Lock保证了原子性、可见性、有序性,适合需要互斥的场景。volatile保证可见性和有序性,适合简单的状态标志。原子类用CAS保证原子性,适合计数器这种简单操作。读写锁适合读多写少。ThreadLocal适合线程隔离。根据不同场景选择合适的机制,这就是保证多线程资源一致性的关键。
12. 其他
- 介绍一下实习项目,讲一下全流程对岗位的未来规划
582