扫码加入

  • 正文
  • 相关推荐
申请入驻 产业图谱

面试 | 海康威视薪资开了,果然很体面...

03/03 09:22
582
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

面试刷题网站: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. 其他

    介绍一下实习项目,讲一下全流程对岗位的未来规划

 

相关推荐