2023-07-28
原文作者:说好不能打脸 原文地址:https://yinwj.blog.csdn.net/article/details/54016015

1、概述

之前的两篇文章介绍了图片系统的技术组件选型和技术方案设计,从这篇文章开始我们将搭建工程进行详细的编码开发和效果测试。这里要说明一下,由于文章篇幅的限制不可能贴出所有的代码,这样也不符合读者的阅读习惯。所以笔者的办法是,只通过后续的文章内容介绍详细的设计要点和代码片段,通过这些讲解读者基本可以清楚整个详细设计的思路,而整个图片服务工程代码会上传到了CSDN的下载区(http://download.csdn.net/detail/yinwenjie/9740380),如果对工程感兴趣那么读者可以直接下载——免费下载。

2、简单的图片处理

2-1、位图的构成基础

由于我们将要进行的是图片处理操作,而图片处理比起读者经常涉及的业务系统来说是一个比较生涩的领域,在一般的业务系统中也就只是需要做到上传或者下载/显示图片就OK了。而图片服务是专门进行图片处理的,简单的处理包括透明度、旋转、缩放、翻转、重合等,复杂的处理还有背景虚化、人脸识别等等。所以在正式进入编码前,本文有必要向读者介绍一些基本的图片知识,特别是图片颜色模型的知识。当然,如果读者本身就有很丰富的图片处理知识了,则可以直接跳过本节的介绍。

目前在互联网上使用最多的图片都属于位图,例如JPG、GIF、PNG和BMP图片都属于位图。它们的相同点在于都依靠RGB色彩模式描述图片中的每一个点,从而构成一张图片。它们的不同点则体现在文件头结构、压缩算法、扫描方式以及像素深度支持等方面。举个例子,BMP(bitmap)图片使用一种无损压缩算法能够保证还原所有像素点,但是图片大小过大,所以相同像素规模的图片如果使用BMP格式,需要的存储容量就更大,而且不利于进行网络传输。再例如,PNG图片除了可以存储24位深,还可以存储额外8位甚至16位的Alpha通道描述来表示每个像素点的透明度,但是JPG图片却没有这个特性,它只支持24位深度。

那么什么是GRB呢?这些图片格式的每一个点都由三个基础色进行描述: 红(Red)、绿(Green)、蓝(Blue),通过三原色之间的比例就可以调制出不同的颜色,将一定规模的颜色点组合起来就是一张图片了。一般来说计算机为每一个原色准备了8位bit进行描述(就是1个byte),三原色就是24位bit,并且大多数图片的颜色深度就是24位。

202307282258347251.png

这样看来24位颜色深度的一个图片点,最多可以记录256 * 256 * 256 = 16777216种颜色。那么类似PNG图片使用的32位/48位颜色深度有代表什么意思呢?多出来的8位/16位为了记录这个点的可见度(透明度),这个记录范围的数值又称为Alpha通道。这样同一个颜色配合不同的可见度就可以满足更丰富颜色展示要求。另外,这也是JPG文件不能支持透明度的原因——它的格式规范中不带有对Alpha通道的支持。

202307282258351862.png

另外由于一些图片类型也更浅的位深,所以一些图片为了减少极端情况下的使用/传输空间,也会使用16位/8位的RGB描述模式。例如使用16位RGB模式时,红色位描述为5个bit、绿色位描述为6bit、蓝色位描述为5bit。在Java原生的图片处理模块中,使用TYPE_USHORT_565_RGB、TYPE_USHORT_555_RGB标识对16位RGB模式进行描述。

2-2、JAVA对图片进行处理

JAVA模块中带有的图片处理功能(Java Image I/O API),能够支持对JPG、PNG、BMP、WBMP、GIF格式的文件进行读写操作,而且可以支持到单个像素点级别的操作——也就是说读者可以通过这套API对具体的某一个RBG描述信息进行操作。通过这套API要完成在图片上添加形状、缩放图片、裁剪图片等操作也非常简单,只需要几行代码。首先我们来看一下Java Image I/O API中会使用的几个概念,以便后续进行代码编写:

2-2-1、BufferedImage对象

从这个类的直观名称可以理解成被缓存的图片信息,它将一张图片经过格式分析后,放置在内存中的一个可访问区域。开发热源可以从这个可访问区域提取到很多关于这张图片有用的信息,例如可以取得ColorModel表示的颜色分量,里面包括了每一个像素的RGB信息和Alpha信息;还可以取得Raster表示的像素矩阵,通过它可以读取一个范围内的像素点。开发人员对这个区域进行读写操作,实际上就是对这张图片进行像素级别操作。以下代码可以加载一张位图到BufferedImage中:

    ......
    BufferedImage srcImage = javax.imageio.ImageIO.read(new File("mmexport1444022819048.jpg"));
    ......

需要注意的是,BufferedImage一旦被被加载其像素规模就不能改变了,例如您最初加载了一个800 * 600的JPG文件到BufferedImage中,在操作过程中您就不能将这个BufferedImage缩小成600 * 400的。如果要这样做,您只能创建一个新的BufferedImage,并将进行缩放后的计算结果加载到这个新的BufferedImage中。以下的方式可以创建一个空的BufferedImage:

    ......
    // 以下代码创建一个600 * 400的BufferedImage
    BufferedImage outputImage = new BufferedImage(600, 400, BufferedImage.TYPE_INT_RGB);
    ......

注意最有一个参数BufferedImage.TYPE_INT_RGB,它指定了BufferedImage需要支持的RGB规则。TYPE_INT_RGB表示使用一个int数值表示24位深度的RGB规则,TYPE_USHORT_565_RGB表示,使用一个short数值,表示16位深度的RGB规则,其中红色占5位,绿色占6位,蓝色占5位。再例如TYPE_INT_ARGB和TYPE_4BYTE_ABGR分别表示以int数值或一个4位的byte数组表示ARGB规则,换句话说就是这个BufferedImage支持图片透明度的表示。

最直观的理解就是, BufferedImage相当于在内存区域中的画布,这个画布上可以有一张或者多张图片,也可以没有任何图片。你可以在画布上对每个像素进行读写操作,但是你不能改变画布的大小

2-2-2、Graphics对象

Graphics类抽象一点说是进行图形操作的上下文控制的类,具体一点说是图形画笔工具。通过这个类(以及它的子类)开发人员可以方便的在画布上绘制不同的规则形状、图片或者文字。

202307282258356673.png

上图中呈现的Graphics子类结构可以支持不同的绘图场景,例如Graphics2D类基本上可以处理所有主流2维位图的画布绘制,在我们的图片服务系统中使用最多的画笔类也是它。以下代码可以在画布上绘制一个规则的矩形,并填充颜色:

    ......
    // 创建一个100 * 80 的画布
    BufferedImage outputImage = new BufferedImage(100, 80, BufferedImage.TYPE_INT_RGB);
    // 获得这个画布的画笔,也可以使用createGraphics创建画笔
    Graphics graphics = outputImage.getGraphics();
    // 从画布上10,10的坐标开始,绘制一个60 * 40的矩形
    graphics.setColor(Color.RED);
    graphics.drawRect(10, 10, 60, 40);
    // 处理
    graphics.dispose();
    ......

输出到文件,就可以看到如以下所示的效果了。

202307282258362944.png

红色矩形在画布上被绘制出来,但为什么初始化的画布是黑色呢?这是因为我们使用的BufferedImage构造方法将使用RGB=‭0000 0000 0000 0000 0000 0000的数值进行每个像素点的初始化,实际上就是黑色的RGB值。我们换一种方式进行BufferedImage的初始化,就可以将BufferedImage中的像素点初始化成白色:

    ......
    int width = 100, height = 80;
    int size = width * height;
    int[] pixels = new int[size];
    // 现在设置每一个像素点的RGB值为白色(整数的表示就是‭16777215‬,16进制的表示就是FFFFFF)
    for(int index = 0 ; index < size ; index++) {
        pixels[index] = 0xFFFFFF;
    }
    // size就是像素规模大小
    DataBuffer dataBuffer = new DataBufferInt(pixels, size);
    // 初始化的Raster类,就是像素矩形数组的封装
    WritableRaster raster = Raster.createPackedRaster(dataBuffer, width, height, width, new int [] { 0xFF0000, 0xFF00, 0xFF }, null );
    DirectColorModel directColorModel = new DirectColorModel(24, 0xFF0000, 0xFF00, 0xFF);
    // 生成 BufferedImage, 这样BufferedImage中的每个像素就是白色了
    BufferedImage outputImage = new BufferedImage(directColorModel, raster, true , null );
    ......

2-3、JVM进行针对性优化

上文多处已经提到,图片处理操作是计算密集型操作,非常消耗CPU资源和内存资源。而JAVA Image I/O API又是基于java进行的图片像素级操作,其处理性能本身就不及C/C++。所以对JVM的内存优化就显得非常重要了。这里我们假设读者已经知道了JVM的基本构造,直接讲解JVM的几个优化注意点:

  • 关于-Xmx最大堆内存:虽然我们假设的图片处理场景是一个百万级PV的中等电商平台/对C端系统,所以单个图片服务系统单位时间内需要处理的图片请求数量也是比较大的,首先建议设置Xmx内存大小在8+GB或者设置内存大小为操作系统可用内存的60%以上。注意也不能太大了,这要依据您的CPU性能设定,否则就会出现在时不时进行full gc出现明显卡顿现象——CPU性能不够造成full gc耗时过长。

  • 关于回收器的选择: 回收器的设置是最关键的 ,我们知道在早期的JDK版本中提供的回收器,都是采用中断用户线程的方式进行,无论是针对新生代的Serial、ParNew还是针对年老代的SerialOld,都是这样。但如果在高并发环境下,如果出现用户线程的停顿现象,就会在非常短的时间内造成大量请求等待,严重影响服务器的处理效率。所以对JVM的优化要特别注意这个点,建议 采用JDK 1.7+ 64位以上的运行版本,并设置JVM到server模式

    首先是指定年老代使用的回收器,这个推荐使用标记-清除算法的CMS(响应时间优先回收器)就好了,它会在尽量保证用户线程运行的情况下对待回收区域进行多次标标记回收。还要注意,CMS回收器并不能保证在回收时用户线程绝对不停止,而是使用两次短暂的挂起操作取代之前回收器使用的一次较长的挂起操作。另外注意,CMS的线程数量有一个默认值,这个默认值是(cpu内核数量 + 3)/4。虽然这个数量是可以设置的,但是笔者并不建议自己去设定这个值,而是保证您的操作系统上至少有8个或以上(16个最佳)CPU内和数量(如果达到或超过24核,就要通过-XX:ParallelGCThreads参数控制一下了),-XX:+UseConcMarkSweepGC参数可开启CMS。

    接着是新生代使用的回收器,如果您指定了年老代使用回收器为CMS,那么新生代的回收器就不能使用ParallelScavenge回收器了(吞吐量优先回收器),因为两种回收器不兼容。首先CMS也支持 可以使用ParNew回收器,这是一个单线程Serial GC的一个多线程版本,虽然在进行GC时会出现用户线程挂起的情况,但由于它是多线程的版本且我们存储的数据特点,决定了ParNew不会出现太大的性能瓶颈。

  • 关于年老代和新生代的比例问题:虽然我们常说JVM优化的目的减少移动到年老代的对象数量和减少full gc的情况,但由于我们在内存中将要存储和操作的数据比较特别——图片文件数据,形象来说是BufferedImage对象、较大的byte[]数组对象。这首先些对象的特点是数据量较大,一张原始图片小则500KB,大则会达到1MB(不会再大了,因为我们对上传文件的大小做了限制)。其次图片处理速度较慢,一张颜色深度在24位大小在1MB的JPG文件,等比例缩放成一张200KB的图片耗费的时间在200ms左右,甚至有时会超过500ms(看CPU性能了等客观因素了)。所以这样的数据存放存在年轻代区域,就会造成非常频繁的复制操作和向年老代的移动操作。这样我们的优化思路就需要有针对性: 减少年轻代的内存空间,并设置一个阈值,在图片数据超过这个阈值时就直接将对象放入年老代 ,然后在年老代中由CMS GC进行回收。例如当JVM堆内存数量为8GB时,可以通过-Xmn参数设置年轻代(eden+ 2 survivor space)的空间大小为1GB;通过-XX:PretenureSizeThreshold= 参数设置直接进入年老代的对象大小值;通过-XX:CMSInitiatingOccupancyFraction=50参数设定当年老代已使用的内存大小达到50%时,开始CMS GC。

  • 关于持久代的问题:在我们的图片服务中,JVM的持久代并不会存储太多数据,特别是我们的工程中需要加载IOC容器的class信息并不多,且常量信息也不多的情况下。况且在JDK Version 1.8+ 的版本中JVM模型已经取消了对持久代的支持。所以持久代并不需要做太多针对性的优化,最后JDK Version 1.8+ 也是本文推荐使用的。

2-4、其它说明

除了JAVA 提供的JAVA Image I/O API以外,还有一些基于JAVA开发的第三方图形组件,不过如果您搜索Goolge就会发现很多第三方图形组件已经没有再维护了,如果各位读者有兴趣可以进行研究:例如Java Image Filters、JMagick等。

JAVA提供的 JAVA Image I/O API 图片处理工具虽然可以进行像素级别的操作,但是相对于C/C++提供的图片处理性能来说还是较弱 ,那么要进行图形高效运算的语言基础还是C/C++为宜。目前流行的2D和3D图像处理软件也多是基于C/C++构建,例如OpenGL、DirectX等。另外图形处理都是CPU密集型工作,对计算机的运算资源和内存资源要求都比较高,目前的发展趋势是使用专门的GPU代替CPU进行运算,这也是为什么无论是我们使用JAVA Image I/O API还是基于Nginx的Image模块为系统提供简单的图片处理功能,对CPU要求都非常高的原因。

=============================
(接下文)

阅读全文