Java面试总结(十三)

Posted 路上阡陌

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java面试总结(十三)相关的知识,希望对你有一定的参考价值。

字符型常量和字符串常量的区别?

  1. 形式上: 字符常量是单引号引起的一个字符,字符串常量是双引号引起的若干个字符;

  2. 含义上: 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算;字符串常量代表一个地址值(该字符串在内存中存放位置,相当于对象;

  3. 占内存大小:字符常量只占2个字节;字符串常量占若干个字节(至少一个字符结束标志) (注意: char 在Java中占两个字节)。

String、StringBuffer、StringBuilder 的区别?

可变性

String 是不可变的(后面会详细分析原因)。

StringBuilder 与 StringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串,不过没有使用 final 和 private 关键字修饰,最关键的是这个 AbstractStringBuilder 类还提供了很多修改字符串的方法比如 append 方法。

abstract class AbstractStringBuilder implements Appendable, CharSequence 
    char[] value;
    public AbstractStringBuilder append(String str) 
        if (str == null)
            return appendNull();
        int len = str.length();
        ensureCapacityInternal(count + len);
        str.getChars(0, len, value, count);
        count += len;
        return this;
    
  	//...

线程安全性

String 中的对象是不可变的,也就可以理解为常量,线程安全。AbstractStringBuilder 是 StringBuilder 与 StringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共方法。StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。

性能

每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。

对于三者使用的总结:

  1. 操作少量的数据: 适用 String;
  2. 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder;
  3. 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer。

总结:

  1. 可变性:String 不可变,StringBuilder 和 StringBuffer 可变(都继承了 AbstractStringBuilder);
  2. 线程安全性:String 不可变,是线程安全的,StringBuffer 对方法加了同步锁(需要被调用的方法)是线程安全的,StringBuilder 没有对方法加同步锁,是线程不安全的;
  3. 性能:String 对象每次改变都会创建新的对象,性能比较低,StringBuffer 因为需要在方法上加同步锁性能低于 StringBuilder。

String 为什么是不可变的?

public final class String implements java.io.Serializable, Comparable<String>, CharSequence 
    private final char value[];
	//...

我们知道被 final 关键字修饰的类不能被继承,修饰的方法不能被重写,修饰的变量是基本数据类型则值不能改变,修饰的变量是引用类型则不能再指向其他对象。因此,final 关键字修饰的数组保存字符串并不是 String 不可变的根本原因,因为这个数组保存的字符串是可变的(final 修饰引用类型变量的情况)。String 真正不可变有下面几点原因:

  1. 保存字符串的字符数组被 final 修饰且为私有,并且 String 类没有提供/暴露修改这个字符数组的方法;
  2. String 类被 final 修饰,使其不能被继承,避免了子类通过修改父类方法或者添加新的方法来破坏 String 类的不可变性。

解释如下:

String 类中保存字符串的字符数组被声明为私有 final char[] value,这意味着它不能被外部代码修改。同时,String 类也没有提供任何公共方法来修改这个字符数组,因此外部代码也无法直接修改它。这样做的目的是为了保护字符串的不可变性,防止外部代码修改字符串的内容。

如果外部代码需要对字符串进行修改操作,可以通过 String 类提供的一些方法来实现,例如 substring()、concat()、replace() 等方法,这些方法会返回一个新的字符串对象,而不是修改原有的字符串。这种设计可以保证字符串的不可变性和线程安全性,同时也可以提高字符串操作的效率,避免了频繁创建和销毁对象的开销。

String 类被 final 修饰导致其不能被继承,进而避免了子类破坏 String 不可变。如果 String 类可以被继承,那么子类可能会通过修改父类的方法或者添加新的方法来破坏字符串的不可变性。为了避免这种情况的发生,Java 设计者将 String 类声明为 final 类,从而保证了它的不可变性和安全性。这也是 Java 中许多常用类都被声明为 final 的原因之一。

在 Java 9 之后,String 、StringBuilder 与 StringBuffer 的实现改用 byte 数组存储字符串。

public final class String implements java.io.Serializable,Comparable<String>, CharSequence 
    // @Stable 注解表示变量最多被修改一次,称为“稳定的”。
    @Stable
    private final byte[] value;


abstract class AbstractStringBuilder implements Appendable, CharSequence 
    byte[] value;

 

Java 9 为何要将 String 的底层实现由 char[] 改成了 byte[] ?

新版的 String 其实支持两个编码方案: Latin-1 和 UTF-16。如果字符串中包含的汉字没有超过 Latin-1 可表示范围内的字符,那就会使用 Latin-1 作为编码方案。Latin-1 编码方案下,byte 占一个字节(8 位),char 占用 2 个字节(16),byte 相较 char 节省一半的内存空间。

JDK 官方就说了绝大部分字符串对象只包含 Latin-1 可表示的字符。

如果字符串中包含的汉字超过 Latin-1 可表示范围内的字符,byte 和 char 所占用的空间是一样的。

String为什么要设计成不可变的?

设计考虑

只有当字符串是不可变的,字符串池才有可能实现。
字符串池的实现可以在运行时节约很多heap空间,因为不同的字符串变量都指向池中的同一个字符串。
但如果字符串是可变的,那么String interning将不能实现
(译者注:String interning是指对不同的字符串仅仅只保存一个,即不会保存多个相同的字符串),
因为这样的话,如果变量改变了它的值,那么其它指向这个值的变量的值也会一起改变。(设计考虑)

安全考虑

如果字符串是可变的,那么会引起很严重的安全问题。
譬如,数据库的用户名、密码都是以字符串的形式传入来获得数据库的连接,
或者在socket编程中,主机名和端口都是以字符串的形式传入。
因为字符串是不可变的,所以它的值是不可改变的,否则黑客们可以钻到空子,改变字符串指向的对象的值,造成安全漏洞。(安全性)

因为字符串是不可变的,所以是多线程安全的,同一个字符串实例可以被多个线程共享。
这样便不用因为线程安全问题而使用同步。字符串自己便是线程安全的。(安全性)

类加载器要用到字符串,不可变性提供了安全性,以便正确的类被加载。
譬如你想加载java.sql.Connection类,而这个值被改成了myhacked.Connection,那么会对你的数据库造成不可知的破坏。(安全性)

效率优化

因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。
这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。
这就是HashMap中的键往往都使用字符串。(效率优化)

总体来说,String不可变的原因要包括 设计考虑(字符串常量池),效率优化(hashcode在创建时就可以被缓存),以及安全性(线程安全)这三大方面。

String 是最基本的数据类型吗?

不是。Java 中的基本数据类型只有 8 个 :byte、short、int、long、float、double、char、boolean;除了基本类型(primitive type),剩下的都是引用类型(referencetype),Java 5 以后引入的枚举类型也算是一种比较特殊的引用类型。

String有哪些特性?

  • 不变性:String 是只读字符串,是一个典型的 immutable 对象,对它进行任何操作,其实都是创建一个新的对象,再把引用指向该对象。不变模式的主要作用在于当一个对象需要被多线程共享并频繁访问时,可以保证数据的一致性;

  • 常量池优化:String 对象创建之后,会在字符串常量池中进行缓存,如果下次创建同样的对象时,会直接返回缓存的引用;

  • final:使用 final 来定义 String 类,表示 String 类不能被继承,提高了系统的安全性。

013期JavaSE面试题(十三):多线程

开篇介绍

大家好,我是Java最全面试题库提裤姐,今天这篇是JavaSE系列的第十三篇,主要总结了Java中的多线程问题,多线程分为三篇来讲,这篇是第三篇,在后续,会沿着第一篇开篇的知识线路一直总结下去,做到日更!如果我能做到百日百更,希望你也可以跟着百日百刷,一百天养成一个好习惯。

volatile关键字的作用?

对于可见性,Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。主要的原理是使用了内存指令。

  • LoadLoad重排序:一个处理器先执行一个L1读操作,再执行一个L2读操作;但是另外一个处理器看到的是先L2再L1
  • StoreStore重排序:一个处理器先执行一个W1写操作,再执行一个W2写操作;但是另外一个处理器看到的是先W2再W1
  • LoadStore重排序:一个处理器先执行一个L1读操作,再执行一个W2写操作;但是另外一个处理器看到的是先W2再L1
  • StoreLoad重排序:一个处理器先执行一个W1写操作,再执行一个L2读操作;但是另外一个处理器看到的是先L2再W1

说一下volatile关键字对原子性、可见性以及有序性的保证

在volatile变量写操作的前面会加入一个Release屏障,然后在之后会加入一个Store屏障,这样就可以保证volatile写跟Release屏障之 前的任何读写操作都不会指令重排,然后Store屏障保证了,写完数据之后,立马会执行flush处理器缓存的操作 。
在volatile变量读操作的前面会加入一个Load屏障,这样就可以保证对这个变量的读取时,如果被别的处理器修改过了,必须得从其他 处理器的高速缓存(或者主内存)中加载到自己本地高速缓存里,保证读到的是最新数据; 在之后会加入一个Acquire屏障,禁止volatile读操作之后的任何读写操作会跟volatile读指令重排序。
与volatie读写内存屏障对比一下,是类似的意思。
Acquire屏障其实就是LoadLoad屏障 + LoadStore屏障
Release屏障其实就是StoreLoad屏障 + StoreStore屏障

什么是CAS?

CAS(compare and swap)的缩写。Java利用CPU的CAS指令,同时借助JNI来完成Java的非阻塞算法,实现原子操作。其它原子操作都是利用类似的特性完成的。
CAS有3个操作数:内存值V旧的预期值A要修改的新值B
当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
CAS的缺点:

  • CPU开销过大
    在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很到的压力。
  • 不能保证代码块的原子性
    CAS机制所保证的知识一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用synchronized了。
  • ABA问题
    这是CAS机制最大的问题所在。

什么是AQS?

AQS,即AbstractQueuedSynchronizer,队列同步器,它是Java并发用来构建锁和其他同步组件的基础框架。
同步组件对AQS的使用:
AQS是一个抽象类,主是是以继承的方式使用。
AQS本身是没有实现任何同步接口的,它仅仅只是定义了同步状态的获取和释放的方法来供自定义的同步组件的使用。从图中可以看出,在java的同步组件中,AQS的子类(Sync等)一般是同步组件的静态内部类,即通过组合的方式使用。
抽象的队列式的同步器,AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch
它维护了一个volatile int state(代表共享资源)和一个FIFO(双向队列)线程等待队列(多线程争用资源被阻塞时会进入此队列)

Semaphore是什么?

Semaphore就是一个信号量,它的作用是限制某段代码块的并发数。
semaphore有一个构造函数,可以传入一个int型整数n,表示某段代码最多只有n个线程可以访问,如果超出了n,那么请等待,等到某个线程执行完毕这段代码块,下一个线程再进入。
由此可以看出如果Semaphore构造函数中传入的int型整数n=1,相当于变成了一个synchronized了。

public static void main(String[] args) {  
        int N = 8; //工人数  
        Semaphore semaphore = new Semaphore(5); //机器数目  
        for(int i=0;i<N;i++)  
            new Worker(i,semaphore).start();  
    }      
    static class Worker extends Thread{  
        private int num;  
        private Semaphore semaphore;  
        public Worker(int num,Semaphore semaphore){  
            this.num = num;  
            this.semaphore = semaphore;  
        }          
        @Override  
        public void run() {  
            try {  
                semaphore.acquire();  
                System.out.println("工人"+this.num+"占用一个机器在生产...");  
                Thread.sleep(2000);  
                System.out.println("工人"+this.num+"释放出机器");  
                semaphore.release();              
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
        }  
    }  

Synchronized的原理是什么?

Synchronized是由JVM实现的一种实现互斥同步的方式,查看被Synchronized修饰过的程序块编译后的字节码,会发现,被Synchronized修饰过的程序块,在编译前后被编译器生成了monitorenter和monitorexit两个字节码指令。
在虚拟机执行到monitorenter指令时,首先要尝试获取对象的锁:如果这个对象没有锁定,或者当前线程已经拥有了这个对象的锁,把锁的计数器+1;
当执行monitorexit指令时,将锁计数器-1;当计数器为0时,锁就被释放了。如果获取对象失败了,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
Java中Synchronize通过在对象头设置标志,达到了获取锁和释放锁的目的。

为什么说Synchronized是非公平锁?

非公平主要表现在获取锁的行为上,并非是按照申请锁的时间前后给等待线程分配锁的,每当锁被释放后,任何一个线程都有机会竞争到锁,这样做的目的是为了提高执行性能,缺点是可能会产生线程饥饿现象。

JVM对java的原生锁做了哪些优化?

在Java6之前, Monitor的实现完全依赖底层操作系统的互斥锁来实现.

由于Java层面的线程与操作系统的原生线程有映射关系,如果要将一个线程进行阻塞或唤起都需要操作系统的协助,这就需要从用户态切换到内核态来执行,这种切换代价十分昂贵,很耗处理器时间,现代JDK中做了大量的优化。
一种优化是使用自旋锁,即在把线程进行阻塞操作之前先让线程自旋等待一段时间,可能在等待期间其他线程已经解锁,这时就无需再让线程执行阻塞操作,避免了用户态到内核态的切换。
现代JDK中还提供了三种不同的 Monitor实现,也就是三种不同的锁:

  • 偏向锁(Biased Locking)
  • 轻量级锁
  • 重量级锁
    这三种锁使得JDK得以优化 Synchronized的运行,当JVM检测到不同的竞争状况时,会自动切换到适合的锁实现,这就是锁的升级、降级。当没有竞争出现时,默认会使用偏向锁。
    JVM会利用CAS操作,在对象头上的 Mark Word部分设置线程ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁,因为在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏向锁可以降低无竞争开销。
    如果有另一线程试图锁定某个被偏向过的对象,JVM就撤销偏向锁,切换到轻量级锁实现。
    轻量级锁依赖CAS操作 Mark Word来试图获取锁,如果重试成功,就使用普通的轻量级锁否则,进一步升级为重量级锁。

Synchronized和 ReentrantLock的异同?

synchronized
是java内置的关键字,它提供了一种独占的加锁方式。synchronized的获取和释放锁由JVM实现,用户不需要显示的释放锁,非常方便。然而synchronized也有一些问题:
当线程尝试获取锁的时候,如果获取不到锁会一直阻塞。
如果获取锁的线程进入休眠或者阻塞,除非当前线程异常,否则其他线程尝试获取锁必须一直等待。

ReentrantLock
ReentrantLock是Lock的实现类,是一个互斥的同步锁。ReentrantLock是JDK 1.5之后提供的API层面的互斥锁,需要lock()和unlock()方法配合try/finally语句块来完成。
等待可中断避免,出现死锁的情况(如果别的线程正持有锁,会等待参数给定的时间,在等待的过程中,如果获取了锁定,就返回true,如果等待超时,返回false)
公平锁与非公平锁多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁,Synchronized锁非公平锁,ReentrantLock默认的构造函数是创建的非公平锁,可以通过参数true设为公平锁,但公平锁表现的性能不是很好。

从功能角度
ReentrantLock比 Synchronized的同步操作更精细(因为可以像普通对象一样使用),甚至实现 Synchronized没有的高级功能,如:

  • 等待可中断当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,对处理执行时间非常长的同步块很有用。
  • 带超时的获取锁尝试在指定的时间范围内获取锁,如果时间到了仍然无法获取则返回。
  • 可以判断是否有线程在排队等待获取锁。
  • 可以响应中断请求与Synchronized不同,当获取到锁的线程被中断时,能够响应中断,中断异常将会被抛出,同时锁会被释放。
  • 可以实现公平锁。

从锁释放角度
Synchronized在JVM层面上实现的,不但可以通过一些监控工具监控 Synchronized的锁定,而且在代码执行出现异常时,JVM会自动释放锁定,但是使用Lock则不行,Lock是通过代码实现的,要保证锁定一定会被释放,就必须将 unLock()放到 finally{}中。

从性能角度
Synchronized早期实现比较低效,对比 ReentrantLock,大多数场景性能都相差较大。
但是在Java6中对其进行了非常多的改进,
在竞争不激烈时:Synchronized的性能要优于 ReetrantLock;
在高竞争情况下:Synchronized的性能会下降几十倍,但是 ReetrantLock的性能能维持常态。







































以上是关于Java面试总结(十三)的主要内容,如果未能解决你的问题,请参考以下文章

JavaEE面试总结

jvm--深入理解java虚拟机 精华总结(面试)(转)

JVM面试题总结及分析

Java基础+集合+多线程+JVM 面试题总结

013期JavaSE面试题(十三):多线程

Java经典面试题汇总(十三)Dubbo